Compare commits
1195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8956642e05 | ||
|
|
791fad680a | ||
|
|
8c60d6a03d | ||
|
|
7391b4e61c | ||
|
|
266be281d0 | ||
|
|
ac487a5fa5 | ||
|
|
7d1bb0d3f7 | ||
|
|
e8d9cf8f00 | ||
|
|
7e7ae7dddf | ||
|
|
25cb444335 | ||
|
|
fa14fc7c52 | ||
|
|
6e51e65ba8 | ||
|
|
19b1bf3fde | ||
|
|
316b4708c6 | ||
|
|
4fd9a0381f | ||
|
|
88cce738d7 | ||
|
|
9720c11ecc | ||
|
|
ca4c490091 | ||
|
|
1749f0a0b8 | ||
|
|
c9ff1aa668 | ||
|
|
49776731bf | ||
|
|
9bf1ab29a8 | ||
|
|
4e51b44b44 | ||
|
|
582803dccc | ||
|
|
5f9fd31dfd | ||
|
|
74402f640f | ||
|
|
963da0b5d0 | ||
|
|
85493de179 | ||
|
|
2f8afeb814 | ||
|
|
3b004567df | ||
|
|
76039aa5e6 | ||
|
|
1f10692c15 | ||
|
|
6dd34b292f | ||
|
|
77a9baa34f | ||
|
|
6e2ad86fb2 | ||
|
|
be54187b48 | ||
|
|
6d56a94bca | ||
|
|
ccc1737f2d | ||
|
|
58e244af21 | ||
|
|
93839c69e2 | ||
|
|
61b00d8531 | ||
|
|
63b1d0dff4 | ||
|
|
2293c663b9 | ||
|
|
f5c77ddffd | ||
|
|
1cf75a5d42 | ||
|
|
7fd655f96f | ||
|
|
096889d6cb | ||
|
|
67c48f5022 | ||
|
|
8e48d63ebb | ||
|
|
aa6ab03555 | ||
|
|
fc02a4aa7e | ||
|
|
6223ef0ef0 | ||
|
|
76e6084419 | ||
|
|
90d72464cb | ||
|
|
82e707a6f6 | ||
|
|
b4f5d92f5d | ||
|
|
985629446a | ||
|
|
64ccb485b5 | ||
|
|
3f248d363f | ||
|
|
40756469ec | ||
|
|
306ab103b6 | ||
|
|
994d510ed9 | ||
|
|
8b8edba64d | ||
|
|
678515403c | ||
|
|
e6404d075e | ||
|
|
7c0db00b75 | ||
|
|
e4e061cf1c | ||
|
|
61021b33b8 | ||
|
|
a33589a9a1 | ||
|
|
3547046cc1 | ||
|
|
95983c73b1 | ||
|
|
499c0c6b70 | ||
|
|
33e2c4bf31 | ||
|
|
589a664971 | ||
|
|
67bf926ed8 | ||
|
|
040020e7ed | ||
|
|
911859ef49 | ||
|
|
68bdff53cf | ||
|
|
632937101a | ||
|
|
639f006a10 | ||
|
|
9e27418f80 | ||
|
|
c1448dfdd5 | ||
|
|
5f396a0993 | ||
|
|
e10c2adafe | ||
|
|
b0f6e4e819 | ||
|
|
afd3be608c | ||
|
|
aef8cb5cb5 | ||
|
|
53664857c1 | ||
|
|
c8b571b38f | ||
|
|
b3bd93d71c | ||
|
|
57ffa71c86 | ||
|
|
a8a38fa5b7 | ||
|
|
24b5cb5fe0 | ||
|
|
955bea2037 | ||
|
|
5ed7b822e1 | ||
|
|
b40c81e79a | ||
|
|
7a98fe3920 | ||
|
|
6792b2a628 | ||
|
|
c12b30a956 | ||
|
|
256de377cf | ||
|
|
a3c405912a | ||
|
|
4e73ea6e6a | ||
|
|
c9b45848dd | ||
|
|
fd0d8b15f7 | ||
|
|
7d0e1c809f | ||
|
|
5cae8e4b19 | ||
|
|
8177e03387 | ||
|
|
f4923314d8 | ||
|
|
24787578bc | ||
|
|
1f4e7cb054 | ||
|
|
92e1a1cac8 | ||
|
|
52a773176c | ||
|
|
cedc7180d4 | ||
|
|
728ac0f8dc | ||
|
|
4741c1d33f | ||
|
|
aa52f33d58 | ||
|
|
2f18ef5f44 | ||
|
|
4927f237ab | ||
|
|
d3a709642b | ||
|
|
3afcfd5856 | ||
|
|
342c0a2285 | ||
|
|
8161fb00b3 | ||
|
|
f9943dc1c4 | ||
|
|
b2000169b3 | ||
|
|
5e72f17793 | ||
|
|
4a4058d211 | ||
|
|
3b71041b66 | ||
|
|
d30bf49c88 | ||
|
|
3a3b1b712d | ||
|
|
85cfebe0f7 | ||
|
|
e23f6e2cc8 | ||
|
|
68e524594d | ||
|
|
a8931e8b2b | ||
|
|
6c05ed8c6f | ||
|
|
02c0c9c861 | ||
|
|
72930605db | ||
|
|
712c965095 | ||
|
|
4bff1a8b59 | ||
|
|
e55fab71cf | ||
|
|
7ce07c1cc7 | ||
|
|
839467a5e3 | ||
|
|
e5dc3dbf03 | ||
|
|
3fde474a5b | ||
|
|
b14b90396f | ||
|
|
937f3cd074 | ||
|
|
cb16a07451 | ||
|
|
6da6f7044b | ||
|
|
a1709e92ae | ||
|
|
b389a348c1 | ||
|
|
4c53791f4d | ||
|
|
d400ba3957 | ||
|
|
6e258a0ee2 | ||
|
|
205667faa1 | ||
|
|
d9abf72f6a | ||
|
|
96807a945c | ||
|
|
b9cb7cc326 | ||
|
|
efde8fe7bc | ||
|
|
b16845f352 | ||
|
|
42b5c2413c | ||
|
|
2ce6eb95ed | ||
|
|
734960e0f3 | ||
|
|
17d88ca8fe | ||
|
|
954b404031 | ||
|
|
0391eed88e | ||
|
|
f7f4cdc622 | ||
|
|
b7b825f0ef | ||
|
|
3c8f5910df | ||
|
|
cc01d04fb8 | ||
|
|
ac18773ebd | ||
|
|
61dcf7fc91 | ||
|
|
d0d3d5b09a | ||
|
|
ba32473f06 | ||
|
|
642e8523a2 | ||
|
|
2a93113c3f | ||
|
|
e29c7ed0ff | ||
|
|
0b30a02dde | ||
|
|
07d8ce9c41 | ||
|
|
4860c9f188 | ||
|
|
d7751008bd | ||
|
|
558554d086 | ||
|
|
417bff8663 | ||
|
|
eaac2ef4ca | ||
|
|
17ab9c1c65 | ||
|
|
2c19981cd8 | ||
|
|
27261ff871 | ||
|
|
57765a64a7 | ||
|
|
c7253e2211 | ||
|
|
ecd5c65695 | ||
|
|
8c33db170d | ||
|
|
5d5277b028 | ||
|
|
28897500c8 | ||
|
|
ac2e811ed6 | ||
|
|
0737c58dff | ||
|
|
fabf3e18bf | ||
|
|
f19fc2e8c0 | ||
|
|
40b5fc020d | ||
|
|
98a8e20baa | ||
|
|
3cec08db85 | ||
|
|
f5b26028f5 | ||
|
|
4c420d01ca | ||
|
|
7cfd2a45a2 | ||
|
|
b4fcd5f2c9 | ||
|
|
ff81447902 | ||
|
|
00b4f9ac54 | ||
|
|
61adc43b45 | ||
|
|
3783996ba4 | ||
|
|
2ce7f671ba | ||
|
|
2fb1974598 | ||
|
|
3d83de20c2 | ||
|
|
42ebf2fa7b | ||
|
|
0bcb785b30 | ||
|
|
84798fb1c5 | ||
|
|
8f94dc6c4f | ||
|
|
8ce5c037ef | ||
|
|
7ca104e53d | ||
|
|
ff76d8eb7e | ||
|
|
973096ba3f | ||
|
|
b8fcc79f8e | ||
|
|
27012a9658 | ||
|
|
1d5fe3e57e | ||
|
|
40cad1c98f | ||
|
|
5f5bb27a5f | ||
|
|
50d1e8bd4a | ||
|
|
feb8f0b6d7 | ||
|
|
1825e48e92 | ||
|
|
2b8a911a78 | ||
|
|
a95ded1551 | ||
|
|
2d582738e3 | ||
|
|
0c76227bbc | ||
|
|
bd7fb79610 | ||
|
|
59b6b87186 | ||
|
|
b15687a4c6 | ||
|
|
076451ff89 | ||
|
|
d6f55c5987 | ||
|
|
4a85d7ea84 | ||
|
|
3c01fb63c3 | ||
|
|
b423ab0624 | ||
|
|
051d8cf0ef | ||
|
|
1513ad96d8 | ||
|
|
04c2c6377a | ||
|
|
9973f00055 | ||
|
|
53e169ae6b | ||
|
|
234f6fcdbb | ||
|
|
7658dc354c | ||
|
|
3c884e54ca | ||
|
|
d7a37151b7 | ||
|
|
96080cdca0 | ||
|
|
434e45b080 | ||
|
|
829172177c | ||
|
|
30d8b0d5c8 | ||
|
|
a51dd3a998 | ||
|
|
7a6c8a0f95 | ||
|
|
faf18f431e | ||
|
|
5f3119be1f | ||
|
|
2394327d38 | ||
|
|
c7d6ae4cb6 | ||
|
|
7333fd570f | ||
|
|
68a253bc7c | ||
|
|
c11573906a | ||
|
|
87d2d7adae | ||
|
|
dc3b46d2d6 | ||
|
|
f2867deb2f | ||
|
|
553376a99e | ||
|
|
e3d7ae8e0f | ||
|
|
9656bac49f | ||
|
|
a971298982 | ||
|
|
4fe6e9186c | ||
|
|
8bc41f2fcd | ||
|
|
92eb3fc953 | ||
|
|
a8f709d2da | ||
|
|
e711ab84ab | ||
|
|
67bc3377cb | ||
|
|
8618813197 | ||
|
|
c139215603 | ||
|
|
3831ebb01d | ||
|
|
0cd5d97405 | ||
|
|
a8fad358b9 | ||
|
|
6e4c386fe2 | ||
|
|
a4174e84ce | ||
|
|
fe8537c9c1 | ||
|
|
5e4277646b | ||
|
|
71257e4d67 | ||
|
|
c0b7bf76f5 | ||
|
|
5624d77519 | ||
|
|
21156d8fa1 | ||
|
|
a46b9a3d6f | ||
|
|
1ee9a1ae22 | ||
|
|
e884201df4 | ||
|
|
2f32f2e914 | ||
|
|
1d51edc50f | ||
|
|
35b5402853 | ||
|
|
2a1d9e94bc | ||
|
|
9f7af072dc | ||
|
|
648ef76234 | ||
|
|
909fb27bed | ||
|
|
09fdd7e4dc | ||
|
|
983716e009 | ||
|
|
4ec1c0e09c | ||
|
|
0361d25c7b | ||
|
|
c5ca1469dc | ||
|
|
5278b124ca | ||
|
|
fbcedb6c3b | ||
|
|
8ea0a0dad5 | ||
|
|
b1f682d2e6 | ||
|
|
3d3df85845 | ||
|
|
8ebaa2798f | ||
|
|
afc3014b18 | ||
|
|
45a22c47d0 | ||
|
|
0c03db14d4 | ||
|
|
fb5376cdf0 | ||
|
|
01916c4adc | ||
|
|
1d33622c13 | ||
|
|
b86f0ac1b7 | ||
|
|
f7f5579c25 | ||
|
|
3871da048d | ||
|
|
73199121d9 | ||
|
|
dcbb040045 | ||
|
|
bd125f2a1a | ||
|
|
fa52b7ffaf | ||
|
|
36e28ca4f4 | ||
|
|
f2d01e003d | ||
|
|
71e76e02d1 | ||
|
|
fc4129994b | ||
|
|
1421934415 | ||
|
|
a7932adfe4 | ||
|
|
f825304de4 | ||
|
|
37e450ab22 | ||
|
|
be0fc27896 | ||
|
|
4a5fe74a9f | ||
|
|
d050fa0d84 | ||
|
|
e25d4ad311 | ||
|
|
c1d2fcd8cd | ||
|
|
253550b490 | ||
|
|
9fb7cce82c | ||
|
|
f2db9967fb | ||
|
|
a24ff76b6b | ||
|
|
6aa09bf28a | ||
|
|
049a5508cc | ||
|
|
aa208d72c1 | ||
|
|
57e81b0f17 | ||
|
|
dfc51e1399 | ||
|
|
f898e4645b | ||
|
|
2475f817f9 | ||
|
|
80687df82e | ||
|
|
09dcba90a9 | ||
|
|
1ce64804fb | ||
|
|
2a55cd9c92 | ||
|
|
e9a3047f00 | ||
|
|
feb09db3d7 | ||
|
|
e179e57459 | ||
|
|
cad26ff8f9 | ||
|
|
9a6313bfae | ||
|
|
46964b5f66 | ||
|
|
1011b2f549 | ||
|
|
2e8fae99ed | ||
|
|
e4a9520908 | ||
|
|
52ab4fa43e | ||
|
|
0519006bb2 | ||
|
|
a9763b7a11 | ||
|
|
7cd03d7a2b | ||
|
|
a4858761c6 | ||
|
|
76021aa1c6 | ||
|
|
16e3c8235e | ||
|
|
df620cde86 | ||
|
|
9d44642fea | ||
|
|
6890e0d0c2 | ||
|
|
b8c19c6046 | ||
|
|
503aac530a | ||
|
|
35438f59fb | ||
|
|
189efb379f | ||
|
|
134961abfd | ||
|
|
ebf62fe161 | ||
|
|
4c638ab608 | ||
|
|
f1ef2f4d5a | ||
|
|
b562f90b25 | ||
|
|
2454e8e4b9 | ||
|
|
5f1682c0c6 | ||
|
|
cce7194aa1 | ||
|
|
c1b0b333b8 | ||
|
|
373f4eeb23 | ||
|
|
836d812ef3 | ||
|
|
6624ac8f31 | ||
|
|
c5595bb61b | ||
|
|
92d85304f2 | ||
|
|
1a5bd638c0 | ||
|
|
e813a80a5b | ||
|
|
c380d37cf9 | ||
|
|
3821a7061b | ||
|
|
6fd541060e | ||
|
|
dbe71c3be4 | ||
|
|
aaf2257117 | ||
|
|
92320c8922 | ||
|
|
a653bff29e | ||
|
|
6699d829e5 | ||
|
|
4714364517 | ||
|
|
056c66ebec | ||
|
|
9cec490855 | ||
|
|
1a38676ff0 | ||
|
|
851e52c3e1 | ||
|
|
cf66cef60a | ||
|
|
38bb5dc7cd | ||
|
|
50b051a864 | ||
|
|
a7301eb909 | ||
|
|
90697652b0 | ||
|
|
fd2183b7f0 | ||
|
|
1363010da8 | ||
|
|
609544ddd4 | ||
|
|
b116f062e9 | ||
|
|
9a9f894215 | ||
|
|
ab867c1a67 | ||
|
|
1753534e20 | ||
|
|
2932f32771 | ||
|
|
0e2c8c6056 | ||
|
|
2976ceaa22 | ||
|
|
f0c6d884ef | ||
|
|
5d95748640 | ||
|
|
c511bf053e | ||
|
|
468ecda450 | ||
|
|
42676789c1 | ||
|
|
af6e0f69be | ||
|
|
2ecb67629e | ||
|
|
4fce750223 | ||
|
|
fcbdfc65ac | ||
|
|
c5dd913b98 | ||
|
|
b0278b92b0 | ||
|
|
128fe2925f | ||
|
|
84cf22a196 | ||
|
|
d3999ca63d | ||
|
|
8de9bf0d6e | ||
|
|
37711f883f | ||
|
|
3922be2642 | ||
|
|
91cef9e506 | ||
|
|
c6a0e8c749 | ||
|
|
e43c9f3b17 | ||
|
|
2bf1e442be | ||
|
|
4b6c8b0634 | ||
|
|
2432039b72 | ||
|
|
74bdbe2975 | ||
|
|
f228a371f2 | ||
|
|
b74007119d | ||
|
|
45243759b8 | ||
|
|
d73bdb264d | ||
|
|
90f8e20843 | ||
|
|
184318078f | ||
|
|
c6befd219c | ||
|
|
2f0ad5cd52 | ||
|
|
60c085ad12 | ||
|
|
bf438c3d99 | ||
|
|
33fdc40f66 | ||
|
|
0082f76b56 | ||
|
|
83a817234d | ||
|
|
540846223b | ||
|
|
6985170378 | ||
|
|
b86b7f7f33 | ||
|
|
c1dd111453 | ||
|
|
7ed54f2bab | ||
|
|
06ef175b46 | ||
|
|
e313e84010 | ||
|
|
6391490f9d | ||
|
|
243bc765f9 | ||
|
|
1b92b79cf0 | ||
|
|
03f19dfb9c | ||
|
|
8f51e790b5 | ||
|
|
c56e097f75 | ||
|
|
30b836895e | ||
|
|
4816520343 | ||
|
|
ef651dbc0a | ||
|
|
1502b10701 | ||
|
|
9cbdccb98b | ||
|
|
3af9bef3f6 | ||
|
|
1db3990cd6 | ||
|
|
6061b20398 | ||
|
|
2e090b192c | ||
|
|
63340a408d | ||
|
|
f9b8f1d8d3 | ||
|
|
f8093bc642 | ||
|
|
cf4d06db16 | ||
|
|
95aadb867a | ||
|
|
3179c6f5de | ||
|
|
63a38b5bf9 | ||
|
|
15c10431e6 | ||
|
|
0f1d5117cc | ||
|
|
4eabf4a02a | ||
|
|
00d7258afd | ||
|
|
b1442b2c7d | ||
|
|
3cab9f737c | ||
|
|
04f0e95653 | ||
|
|
dfb45dd0ff | ||
|
|
02391f04b1 | ||
|
|
31f7122a7f | ||
|
|
480c832852 | ||
|
|
6f5c371510 | ||
|
|
23a9eff43b | ||
|
|
25e3be6fae | ||
|
|
c626d91f26 | ||
|
|
551a574eac | ||
|
|
80c363c316 | ||
|
|
48b180e280 | ||
|
|
223fb3bdec | ||
|
|
5769e34412 | ||
|
|
e3d20c9bdc | ||
|
|
5add1f01c6 | ||
|
|
88e638a56e | ||
|
|
4ca5eb24c3 | ||
|
|
05ef2d766c | ||
|
|
db547cbdb7 | ||
|
|
005d17ee0a | ||
|
|
262ab202fc | ||
|
|
4f4408400f | ||
|
|
f4a374f68c | ||
|
|
77774dc403 | ||
|
|
8f2ab98be0 | ||
|
|
199f0e27fa | ||
|
|
e37f62fe87 | ||
|
|
be7071aae0 | ||
|
|
eae8870d4d | ||
|
|
93c5475a29 | ||
|
|
2861511f1f | ||
|
|
a8aa157484 | ||
|
|
71f3e4cda8 | ||
|
|
9467ae4ee5 | ||
|
|
de710b1dc7 | ||
|
|
3cbc69b11e | ||
|
|
56087870f4 | ||
|
|
b83bd0d5c3 | ||
|
|
3e49ad08b9 | ||
|
|
d5871fe77f | ||
|
|
1b4411d7e5 | ||
|
|
d2254b6ddd | ||
|
|
0d2dad9f3c | ||
|
|
0f77cef37a | ||
|
|
bfc7a8508c | ||
|
|
9e9bc8b4c7 | ||
|
|
f311721386 | ||
|
|
2907514eb7 | ||
|
|
ab084ccf97 | ||
|
|
b399abb471 | ||
|
|
6d648a56b7 | ||
|
|
b3262b2ede | ||
|
|
698fa4625a | ||
|
|
b042ea10ec | ||
|
|
9b682564ee | ||
|
|
813588e8e9 | ||
|
|
ad3ec45a76 | ||
|
|
6f31ed7afc | ||
|
|
c20dc0288f | ||
|
|
a8ac67b9e3 | ||
|
|
a35905dee8 | ||
|
|
bd2fa1cab0 | ||
|
|
4d3dc1fd00 | ||
|
|
e137045812 | ||
|
|
d9ad001c75 | ||
|
|
15ecae54cf | ||
|
|
fedcfc3eea | ||
|
|
9554336860 | ||
|
|
813b325029 | ||
|
|
734d5cb545 | ||
|
|
3557d22c54 | ||
|
|
e4b986fd2e | ||
|
|
3832d9300c | ||
|
|
afb8ddfaf5 | ||
|
|
617c39c0de | ||
|
|
f57bcc37f6 | ||
|
|
37cdaf3e3f | ||
|
|
858c86d907 | ||
|
|
b12b1501e7 | ||
|
|
87172c8757 | ||
|
|
86a1678f41 | ||
|
|
da658f0ae3 | ||
|
|
da23ae9732 | ||
|
|
36b3b8c323 | ||
|
|
d25beeda89 | ||
|
|
9d3e4b8d0c | ||
|
|
4903330e44 | ||
|
|
d81b4c84b8 | ||
|
|
d6572c13b5 | ||
|
|
95c7ccbf34 | ||
|
|
5d19d1ed9f | ||
|
|
93d780f75a | ||
|
|
b75dc4256a | ||
|
|
d0645a1768 | ||
|
|
0690a66806 | ||
|
|
07669043eb | ||
|
|
d579eb81b4 | ||
|
|
cbdcd5f4fc | ||
|
|
bb14b34c2e | ||
|
|
dbbced7401 | ||
|
|
5fb5e12bb8 | ||
|
|
978b83ba67 | ||
|
|
9a01bf2c2c | ||
|
|
1c8fb05ffa | ||
|
|
bec336d2a3 | ||
|
|
51a827093a | ||
|
|
8acd279cfe | ||
|
|
d2444159ac | ||
|
|
2ca2471f5e | ||
|
|
d57ffbdb09 | ||
|
|
f35b8b93b1 | ||
|
|
64c3e3066b | ||
|
|
3e2293195a | ||
|
|
8cd8f80883 | ||
|
|
d7c64141f2 | ||
|
|
3a612558e4 | ||
|
|
9ac2911a55 | ||
|
|
3513c32a62 | ||
|
|
ae87ac82ba | ||
|
|
a8c5a56c1a | ||
|
|
8cebf7829b | ||
|
|
c8a7ae7f73 | ||
|
|
87ab973439 | ||
|
|
06e457a3da | ||
|
|
8e2edb6af3 | ||
|
|
ee391e42e7 | ||
|
|
c078fa69e7 | ||
|
|
da8272b29b | ||
|
|
b3905e062d | ||
|
|
6d48bcc9d0 | ||
|
|
f3a5ed2e72 | ||
|
|
bb700d26f1 | ||
|
|
b2f826dfcc | ||
|
|
c1fae2f91a | ||
|
|
403c470c8a | ||
|
|
8651dae3f3 | ||
|
|
c087858674 | ||
|
|
494e124ac8 | ||
|
|
b7a33d2676 | ||
|
|
dcab21bdde | ||
|
|
cd04ec6339 | ||
|
|
a0a2903706 | ||
|
|
da267562ea | ||
|
|
2ca1d30609 | ||
|
|
cb2017e953 | ||
|
|
c7284c4397 | ||
|
|
986257bef6 | ||
|
|
fbc780b579 | ||
|
|
722043d049 | ||
|
|
f44d6aa25e | ||
|
|
d3834928fd | ||
|
|
09f0e11aa7 | ||
|
|
b706008101 | ||
|
|
4eb3a98c95 | ||
|
|
af0d715423 | ||
|
|
6ae6d8e94e | ||
|
|
df265ead69 | ||
|
|
52a4de0c01 | ||
|
|
bcb079727a | ||
|
|
c0ae9eba9f | ||
|
|
e3ad7787af | ||
|
|
65f2f4f351 | ||
|
|
f07ff56987 | ||
|
|
4a927084c9 | ||
|
|
8a6812e405 | ||
|
|
32cc0468e8 | ||
|
|
a8ffa6f231 | ||
|
|
7ff9146bd1 | ||
|
|
69d379ab10 | ||
|
|
ebc087f1f6 | ||
|
|
3d32ce2f34 | ||
|
|
b122ac06a9 | ||
|
|
543d0be7f2 | ||
|
|
028c6cd43c | ||
|
|
fe338aaf4a | ||
|
|
a923f32070 | ||
|
|
7dac80dcf6 | ||
|
|
c0e1732bbc | ||
|
|
c5c5c28583 | ||
|
|
f3c86484fe | ||
|
|
034993b737 | ||
|
|
f9f1e5f237 | ||
|
|
5cb6e83cd5 | ||
|
|
16a9880583 | ||
|
|
69bfd3cff9 | ||
|
|
3a14207c71 | ||
|
|
25287c8f7f | ||
|
|
4a03596bd3 | ||
|
|
9c66333729 | ||
|
|
728feb1c8e | ||
|
|
c9c47c3133 | ||
|
|
0b2e7c7e31 | ||
|
|
a30f054eb0 | ||
|
|
eafacc3b21 | ||
|
|
a29bf3fce5 | ||
|
|
b30f2d5cc3 | ||
|
|
6bf9330b62 | ||
|
|
f3631b2c2b | ||
|
|
3197c844a5 | ||
|
|
e22351386f | ||
|
|
ee422aacb3 | ||
|
|
380806d27a | ||
|
|
c6840d2356 | ||
|
|
453e42172b | ||
|
|
019bc87eb0 | ||
|
|
ee64a4a035 | ||
|
|
9c67b9bd8e | ||
|
|
3cc90f8c11 | ||
|
|
71daccab12 | ||
|
|
ca86f59736 | ||
|
|
d609656ae3 | ||
|
|
f96e02d9ae | ||
|
|
6c53d59f1a | ||
|
|
b9fd7a5d21 | ||
|
|
669125794f | ||
|
|
4094b63f44 | ||
|
|
b126a70139 | ||
|
|
f30fff5356 | ||
|
|
80e698680b | ||
|
|
39ec7f470b | ||
|
|
16ad7ae5aa | ||
|
|
d54f1bedda | ||
|
|
ad071bb74b | ||
|
|
2422adea21 | ||
|
|
ee7436221e | ||
|
|
889d32cc90 | ||
|
|
ae1835a421 | ||
|
|
8dd13a2ba2 | ||
|
|
dda74782b6 | ||
|
|
4af1d4906c | ||
|
|
ea5e4a2215 | ||
|
|
e80a74cc40 | ||
|
|
d92612c69a | ||
|
|
b1841b02ea | ||
|
|
c5f89dbcf4 | ||
|
|
188623e574 | ||
|
|
0978e93ee7 | ||
|
|
15f4bec197 | ||
|
|
530ee60015 | ||
|
|
4a6ce3b4ee | ||
|
|
9d3743ceb5 | ||
|
|
bc06722633 | ||
|
|
aa3388f732 | ||
|
|
634637f42c | ||
|
|
613fa4343b | ||
|
|
e23f8afed2 | ||
|
|
9a7d24b895 | ||
|
|
45a564729b | ||
|
|
cc2f3733b2 | ||
|
|
77716005c7 | ||
|
|
fed4e9fbde | ||
|
|
5902428c23 | ||
|
|
58ec47517d | ||
|
|
c14f03f0c1 | ||
|
|
2cd49b5898 | ||
|
|
6de95573ff | ||
|
|
19377a8e1c | ||
|
|
0794f061ee | ||
|
|
1c294e1ce4 | ||
|
|
a41aea2d36 | ||
|
|
c93f24970f | ||
|
|
5fc1d21f5a | ||
|
|
8476dd4ace | ||
|
|
2a6dfc8b63 | ||
|
|
424709ca74 | ||
|
|
4b104798b8 | ||
|
|
a15f181008 | ||
|
|
0c38fc2ef4 | ||
|
|
df2652e6cc | ||
|
|
1cc1f1a185 | ||
|
|
4a6d6fa208 | ||
|
|
4f3fb6c1ae | ||
|
|
e4ef0b34c8 | ||
|
|
9e6c700644 | ||
|
|
f182f49f15 | ||
|
|
5d50ebf3aa | ||
|
|
73bb4b3a7f | ||
|
|
dfb9326d5e | ||
|
|
e736cb82d2 | ||
|
|
d471082e3f | ||
|
|
e77c05b908 | ||
|
|
ffa3015ac3 | ||
|
|
f8dcc69a55 | ||
|
|
c04114b07a | ||
|
|
92852f7e27 | ||
|
|
0507f495ad | ||
|
|
d87ff80bef | ||
|
|
27cc86d48d | ||
|
|
7584ad2090 | ||
|
|
087139f4de | ||
|
|
ed4a106bec | ||
|
|
90424e917e | ||
|
|
4870647387 | ||
|
|
046b689882 | ||
|
|
74028fadf7 | ||
|
|
4edcab1542 | ||
|
|
5e75659c48 | ||
|
|
daf8069de2 | ||
|
|
4beafe7cfc | ||
|
|
2a484d51d3 | ||
|
|
b476db188f | ||
|
|
ce08790343 | ||
|
|
1d35574224 | ||
|
|
f1c27e0e8c | ||
|
|
aa405cd6d9 | ||
|
|
8f8c6a1034 | ||
|
|
ece6723211 | ||
|
|
a56cdea207 | ||
|
|
683e76f9a0 | ||
|
|
abd6ad0a64 | ||
|
|
ea4d7693ef | ||
|
|
94b2f473e9 | ||
|
|
f2a27366d3 | ||
|
|
46f2b662f3 | ||
|
|
647e7d478a | ||
|
|
444c3e4fb4 | ||
|
|
35b101a538 | ||
|
|
d3958db8a3 | ||
|
|
be4f19757c | ||
|
|
784d036bb7 | ||
|
|
6a2bcfff19 | ||
|
|
eb7ed1d6dd | ||
|
|
78a9103490 | ||
|
|
0d4b306fc4 | ||
|
|
57f038c704 | ||
|
|
999a98e2ad | ||
|
|
2ada8a27fe | ||
|
|
bd9c8a84b9 | ||
|
|
693e8f195d | ||
|
|
d9851adf65 | ||
|
|
30bd23c088 | ||
|
|
f297923a2f | ||
|
|
41379efd1b | ||
|
|
6339326947 | ||
|
|
a0c1dad2f5 | ||
|
|
25add4239d | ||
|
|
04f1fba581 | ||
|
|
9af20c29d3 | ||
|
|
2b4e003caf | ||
|
|
0f5edcc86c | ||
|
|
52776fcf8d | ||
|
|
2f13099bda | ||
|
|
9ccff3db20 | ||
|
|
ef9b1e7ce5 | ||
|
|
5e770b3975 | ||
|
|
6c92401ad6 | ||
|
|
5b0d604edf | ||
|
|
15258f6cc8 | ||
|
|
001df4cfce | ||
|
|
f42261277e | ||
|
|
1899aac9ae | ||
|
|
72b0555271 | ||
|
|
a649fa8c59 | ||
|
|
ef17b359e2 | ||
|
|
0b91cf7da4 | ||
|
|
4f3769ae38 | ||
|
|
69afb2986e | ||
|
|
39f5374b32 | ||
|
|
ed2b919db4 | ||
|
|
bf67866f8a | ||
|
|
4357e0e038 | ||
|
|
5783da051e | ||
|
|
3528b58174 | ||
|
|
f6e2fe7652 | ||
|
|
2d62fb993f | ||
|
|
11090b57ad | ||
|
|
00d3add0d3 | ||
|
|
9f32abc2ea | ||
|
|
3609a224f1 | ||
|
|
5d3d373abc | ||
|
|
c3712eba27 | ||
|
|
1da0668807 | ||
|
|
1ce009ee73 | ||
|
|
5d1078dea0 | ||
|
|
e1ceb5e8e3 | ||
|
|
912ed0b1eb | ||
|
|
d670f397fc | ||
|
|
7829186d55 | ||
|
|
0c37eccb76 | ||
|
|
7601c7dc4c | ||
|
|
84d746bd2f | ||
|
|
b42ffcec69 | ||
|
|
632e555bed | ||
|
|
c63bbcd574 | ||
|
|
dff7e2323d | ||
|
|
0194790605 | ||
|
|
11eaa803f5 | ||
|
|
c907736436 | ||
|
|
c0c90595fd | ||
|
|
7163a4c6e4 | ||
|
|
cc80022428 | ||
|
|
2f5d00fa3a | ||
|
|
af11f90808 | ||
|
|
27eba987ca | ||
|
|
7e02bd60e5 | ||
|
|
8044f95556 | ||
|
|
56b99630c1 | ||
|
|
cdb9e1fb59 | ||
|
|
6ede0ab7ea | ||
|
|
958edb0140 | ||
|
|
f2f99b5f79 | ||
|
|
f3ccab513b | ||
|
|
7819e863eb | ||
|
|
9f6eb2554a | ||
|
|
b5c792b8d8 | ||
|
|
2b48e889a5 | ||
|
|
688267834d | ||
|
|
c9a411d15d | ||
|
|
a0c074adad | ||
|
|
140722217b | ||
|
|
0e9461e0df | ||
|
|
f851fdcafe | ||
|
|
26358761e5 | ||
|
|
6ce41d3314 | ||
|
|
62c5fa178c | ||
|
|
5f8d8572ad | ||
|
|
16b9ac19f0 | ||
|
|
1bae79265d | ||
|
|
c9cdbe2eb2 | ||
|
|
dfcdc0b9e8 | ||
|
|
957450c0f6 | ||
|
|
20e9880a03 | ||
|
|
503ba36a88 | ||
|
|
d267b32c0d | ||
|
|
7b2b7fada5 | ||
|
|
bcc6634e34 | ||
|
|
4fad05db6b | ||
|
|
c4be0b55d4 | ||
|
|
88d0c11cbc | ||
|
|
a67b295f33 | ||
|
|
01a9ce342a | ||
|
|
6ddb40d146 | ||
|
|
61311f67fe | ||
|
|
8ec0911ce4 | ||
|
|
87e2f33f59 | ||
|
|
92bdf216ca | ||
|
|
73e728177e | ||
|
|
3b4cf5320d | ||
|
|
d5950ab29a | ||
|
|
eff80d684e | ||
|
|
dcc84e0df1 | ||
|
|
49bef912d2 | ||
|
|
8fedb394a4 | ||
|
|
23af906d95 | ||
|
|
ebdb0d0a82 | ||
|
|
b7c0fa94dd | ||
|
|
29857e1185 | ||
|
|
56fb1aeb3d | ||
|
|
dfc1344c69 | ||
|
|
bdf7b0979d | ||
|
|
cee84563fb | ||
|
|
4d9bf9a36b | ||
|
|
ce0c3de40d | ||
|
|
0f8c648d1c | ||
|
|
a1060d1173 | ||
|
|
930efbbe6e | ||
|
|
cb5eedd8c8 | ||
|
|
c7034cf35a | ||
|
|
436f6b4fa9 | ||
|
|
9485b244f5 | ||
|
|
63acc025f9 | ||
|
|
066b20a571 | ||
|
|
f1796451ae | ||
|
|
5ba70c9c6f | ||
|
|
568dc1ef68 | ||
|
|
7d71e8ce64 | ||
|
|
afc27c988d | ||
|
|
70c2c18fb3 | ||
|
|
c8194fad80 | ||
|
|
12541e1ff7 | ||
|
|
99409e8626 | ||
|
|
89781c0a94 | ||
|
|
91841cfc18 | ||
|
|
96255e83ea | ||
|
|
32e81fb074 | ||
|
|
7a14651bd7 | ||
|
|
4f03306aff | ||
|
|
caed7fd079 | ||
|
|
b7111d8a3b | ||
|
|
64799ccc61 | ||
|
|
2d886f3413 | ||
|
|
374a312797 | ||
|
|
ab47bb0ab4 | ||
|
|
bef4507ef6 | ||
|
|
ff2f0d576c | ||
|
|
8192e79d42 | ||
|
|
29860268ba | ||
|
|
de3a746806 | ||
|
|
ce21322117 | ||
|
|
cc395ea0df | ||
|
|
709347db6b | ||
|
|
8558de82b4 | ||
|
|
5c02f82d21 | ||
|
|
b05e6d156d | ||
|
|
44e4e451ad | ||
|
|
3f609f6f2f | ||
|
|
1888c7f00d | ||
|
|
c6d55344c7 | ||
|
|
42092d3d39 | ||
|
|
fbe9c2ba94 | ||
|
|
a8395d8c97 | ||
|
|
fc9a10ad52 | ||
|
|
8b644ee236 | ||
|
|
c7f7f25af0 | ||
|
|
e2af6dd7ac | ||
|
|
a4ad78cec3 | ||
|
|
fd0d3e6e1f | ||
|
|
70287d15a6 | ||
|
|
871598efe6 | ||
|
|
f143bd7fe9 | ||
|
|
9e65eef621 | ||
|
|
0fe26e8a75 | ||
|
|
75bd981dba | ||
|
|
da9da3780a | ||
|
|
cf7930190e | ||
|
|
e1800328fd | ||
|
|
dd86c60636 | ||
|
|
a774f148ee | ||
|
|
9da5328735 | ||
|
|
79e1fdde27 | ||
|
|
fe4b1f8b5e | ||
|
|
1abee60827 | ||
|
|
6ca37bc45a | ||
|
|
558a283e73 | ||
|
|
fe660524a0 | ||
|
|
805053b795 | ||
|
|
c5f33c437f | ||
|
|
0a3700e208 | ||
|
|
976eb91e30 | ||
|
|
ebfdf98612 | ||
|
|
0fb1536055 | ||
|
|
ca385dcf54 | ||
|
|
0d865f05ac | ||
|
|
75b814e26c | ||
|
|
47f53428f6 | ||
|
|
e2c5bba4ae | ||
|
|
7f046ae86b | ||
|
|
a27dd7189a | ||
|
|
34fa0c92b2 | ||
|
|
87f9e1e81b | ||
|
|
a31718d2b9 | ||
|
|
cf4e42358e | ||
|
|
4ce6c2e9b9 | ||
|
|
fbca77debe | ||
|
|
3ebb3e1acf | ||
|
|
f0b9ab4256 | ||
|
|
2b02c1b5b4 | ||
|
|
a882d409cb | ||
|
|
2186b3eb09 | ||
|
|
06efaf2ba2 | ||
|
|
9c0371d41c | ||
|
|
e7004688d0 | ||
|
|
8c69f108cb | ||
|
|
f22f209bee | ||
|
|
1c56505ab0 | ||
|
|
ca1b11b545 | ||
|
|
9397adee03 | ||
|
|
4a83e9af86 | ||
|
|
f22f2780a3 | ||
|
|
f1aba41921 | ||
|
|
a2fb6baba8 | ||
|
|
08eea631d6 | ||
|
|
d62a044522 | ||
|
|
e8211414f9 | ||
|
|
26edd5a2d0 | ||
|
|
bc6a4c11cf | ||
|
|
a91309477b | ||
|
|
3a7b09f025 | ||
|
|
7f2dd68bce | ||
|
|
281a1d40bf | ||
|
|
cf58932fca | ||
|
|
b92a0927f8 | ||
|
|
ab9955b88a | ||
|
|
b00f70ff4b | ||
|
|
9fb05079dc | ||
|
|
1c86728170 | ||
|
|
557b89ba09 | ||
|
|
7cd2d610b1 | ||
|
|
ac27486317 | ||
|
|
907861ea48 | ||
|
|
04c3c2efbc | ||
|
|
fa2e1234e9 | ||
|
|
fec45925c6 | ||
|
|
fcebe2f220 | ||
|
|
00f85a9a96 | ||
|
|
f3bf829ef3 | ||
|
|
1a0cac22f6 | ||
|
|
9aa6941fca | ||
|
|
a164f4c962 | ||
|
|
db86d04b9a | ||
|
|
3cab7a8376 | ||
|
|
2015dcce1f | ||
|
|
03735c2456 | ||
|
|
b283a3db07 | ||
|
|
cb50fc253b | ||
|
|
9d78e5fe57 | ||
|
|
bb648488d6 | ||
|
|
0ae5a99346 | ||
|
|
53899b3a24 | ||
|
|
1ea2f7b531 | ||
|
|
441c47bdc2 | ||
|
|
1de7cefe72 | ||
|
|
eb1dc4c07d | ||
|
|
80126440c8 | ||
|
|
e256ceee0f | ||
|
|
bf2ef70595 | ||
|
|
88b2789128 | ||
|
|
62364fdaf1 | ||
|
|
125a44c645 | ||
|
|
a72a86cfcc | ||
|
|
1a16b1022d | ||
|
|
69fb10fcd9 | ||
|
|
1a4f842f1f | ||
|
|
69dd0235a0 | ||
|
|
ab858a62fd | ||
|
|
01b531aabf | ||
|
|
6ccfae0ab1 | ||
|
|
9cf9ef9a59 | ||
|
|
21fe8fff83 | ||
|
|
780b053219 | ||
|
|
2fbf829eed | ||
|
|
32fb44439d | ||
|
|
8b641cd728 | ||
|
|
d5d4361ec5 | ||
|
|
c69aefef92 | ||
|
|
baf0c180bd | ||
|
|
b46830f010 | ||
|
|
0a4730e5a7 | ||
|
|
e4fe4b576e | ||
|
|
54cfb1191a | ||
|
|
d8072ae73f | ||
|
|
d2e2144148 | ||
|
|
9dfc5e50cc | ||
|
|
4267901630 | ||
|
|
c5f094443a | ||
|
|
70d986f4ac | ||
|
|
d9ccaf7caa | ||
|
|
d767c879c6 | ||
|
|
0caf48919c | ||
|
|
15ec91e446 | ||
|
|
04788ef923 | ||
|
|
79f9a49659 | ||
|
|
834ad68e00 | ||
|
|
8fa85dd47f | ||
|
|
ccbe8fdb1b | ||
|
|
762a41bec6 | ||
|
|
7ed7730574 | ||
|
|
0e3e30391b | ||
|
|
246b0d4915 | ||
|
|
fcf4ba3eb9 | ||
|
|
a7d9252619 | ||
|
|
d4f542cc29 | ||
|
|
2c9f041838 | ||
|
|
90a8209551 | ||
|
|
c0752407b9 | ||
|
|
87fb74b14f | ||
|
|
6336eb9215 | ||
|
|
ee74deaa49 | ||
|
|
00cae6dc52 | ||
|
|
11e3536801 | ||
|
|
427afee8da | ||
|
|
b4da349a8c | ||
|
|
0836f74d17 | ||
|
|
89d49f3e34 | ||
|
|
e813a01e14 | ||
|
|
72e3ef69ff | ||
|
|
94e7508a24 | ||
|
|
0e4de857d4 | ||
|
|
4687ef5288 | ||
|
|
f0b59b8d23 | ||
|
|
976beade85 | ||
|
|
bc54be237b | ||
|
|
61e1fb1192 | ||
|
|
35f2b9629b | ||
|
|
a6a0b905d8 | ||
|
|
79f1a6afa3 | ||
|
|
194306bc1d | ||
|
|
4f10ed7bad | ||
|
|
2edf12670f | ||
|
|
a027cbe776 | ||
|
|
28396d136f | ||
|
|
2fc705dc6e | ||
|
|
6936b24129 | ||
|
|
199dada246 | ||
|
|
8838c23c59 | ||
|
|
5b6db24692 | ||
|
|
019e9f6cf3 | ||
|
|
f37c6f3e70 | ||
|
|
f4efeac36a | ||
|
|
a89e3cf1c9 | ||
|
|
b45fab2855 | ||
|
|
8baf01bc60 | ||
|
|
6e754c1b3a | ||
|
|
a80dc3f165 | ||
|
|
73879c6a99 | ||
|
|
987db155a1 | ||
|
|
6310dfd5c7 | ||
|
|
caf78df17f | ||
|
|
20bd178cf1 | ||
|
|
37ccf7c325 | ||
|
|
823d0b6628 | ||
|
|
ec760ca0d4 | ||
|
|
0ca9bfec0a | ||
|
|
e14bed34bd | ||
|
|
6677577df5 | ||
|
|
c5f5155332 | ||
|
|
e498457395 | ||
|
|
dbf0c27a09 |
26
.envrc
@ -1 +1,25 @@
|
|||||||
layout python3
|
layout uv
|
||||||
|
export LINE_PROFILE=1
|
||||||
|
export MAIL_PASSWORD="ewacyay5seu2qske"
|
||||||
|
export MAIL_PORT=587
|
||||||
|
export MAIL_SERVER="smtp.fastmail.com"
|
||||||
|
export MAIL_USERNAME="kae@midnighthax.com"
|
||||||
|
export MAIL_USE_TLS=True
|
||||||
|
export PYGAME_HIDE_SUPPORT_PROMPT=1
|
||||||
|
|
||||||
|
branch=$(git branch --show-current)
|
||||||
|
|
||||||
|
# Always treat running from /home/kae/mm as production
|
||||||
|
if [ $(pwd) == /home/kae/mm ]; then
|
||||||
|
export MM_ENV="PRODUCTION"
|
||||||
|
export 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 DATABASE_URL="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
|
||||||
|
export PYTHONBREAKPOINT="pudb.set_trace"
|
||||||
|
fi
|
||||||
|
|||||||
13
.flake8
Normal 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
@ -0,0 +1 @@
|
|||||||
|
*.py diff=python
|
||||||
6
.gitignore
vendored
@ -1,8 +1,8 @@
|
|||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
*_ui.py
|
|
||||||
*.pyc
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
tags
|
tags
|
||||||
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
Session.vim
|
Session.vim
|
||||||
*.flac
|
*.flac
|
||||||
@ -11,3 +11,7 @@ StudioPlaylist.png
|
|||||||
*.otl
|
*.otl
|
||||||
*.howto
|
*.howto
|
||||||
.direnv
|
.direnv
|
||||||
|
tmp/
|
||||||
|
.coverage
|
||||||
|
profile_output*
|
||||||
|
kae.py
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
musicmuster
|
3.13
|
||||||
|
|||||||
51
alembic.ini
@ -1,18 +1,29 @@
|
|||||||
# A generic, single database configuration.
|
# a multi-database configuration.
|
||||||
|
|
||||||
[alembic]
|
[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
|
# path to migration scripts
|
||||||
script_location = migrations
|
script_location = migrations
|
||||||
|
|
||||||
# template used to generate migration files
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
# file_template = %%(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.
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
# defaults to the current working directory.
|
# defaults to the current working directory.
|
||||||
prepend_sys_path = .
|
prepend_sys_path = app
|
||||||
|
|
||||||
# timezone to use when rendering the date
|
# timezone to use when rendering the date within the migration file
|
||||||
# within the migration file as well as the filename.
|
# 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()
|
# string value is passed to dateutil.tz.gettz()
|
||||||
# leave blank for localtime
|
# leave blank for localtime
|
||||||
# timezone =
|
# timezone =
|
||||||
@ -30,28 +41,36 @@ prepend_sys_path = .
|
|||||||
# versions/ directory
|
# versions/ directory
|
||||||
# sourceless = false
|
# sourceless = false
|
||||||
|
|
||||||
# version location specification; this defaults
|
# version location specification; This defaults
|
||||||
# to migrations/versions. When using multiple version
|
# to migrations/versions. When using multiple version
|
||||||
# directories, initial revisions must be specified with --version-path
|
# directories, initial revisions must be specified with --version-path.
|
||||||
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
|
# 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
|
# the output encoding used when revision files
|
||||||
# are written from script.py.mako
|
# are written from script.py.mako
|
||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
sqlalchemy.url = mysql+mysqldb://songdb:songdb@localhost/musicmuster
|
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
# post_write_hooks defines scripts or Python functions that are run
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
# on newly generated revision scripts. See the documentation for further
|
# on newly generated revision scripts. See the documentation for further
|
||||||
# detail and examples
|
# detail and examples
|
||||||
|
|
||||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
# hooks=black
|
# hooks = black
|
||||||
# black.type=console_scripts
|
# black.type = console_scripts
|
||||||
# black.entrypoint=black
|
# black.entrypoint = black
|
||||||
# black.options=-l 79
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
[loggers]
|
[loggers]
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from pydub import AudioSegment, effects
|
from pydub import AudioSegment
|
||||||
|
|
||||||
# DIR = "/home/kae/git/musicmuster/archive"
|
# DIR = "/home/kae/git/musicmuster/archive"
|
||||||
DIR = "/home/kae/git/musicmuster"
|
DIR = "/home/kae/git/musicmuster"
|
||||||
|
|||||||
155
app/audacity_controller.py
Normal 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
@ -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
|
||||||
151
app/config.py
@ -1,40 +1,145 @@
|
|||||||
|
# Standard library imports
|
||||||
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# PyQt imports
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
|
AUDACITY_TEST_COMMAND = "Message"
|
||||||
|
AUDACITY_TEST_RESPONSE = "Some message"
|
||||||
|
AUDACITY_TIMEOUT_SECONDS = 20
|
||||||
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
||||||
COLOUR_CURRENT_HEADER = "#d4edda"
|
BITRATE_LOW_THRESHOLD = 192
|
||||||
COLOUR_CURRENT_PLAYLIST = "#28a745"
|
BITRATE_OK_THRESHOLD = 300
|
||||||
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
COLOUR_BITRATE_LOW = "#ffcdd2"
|
||||||
|
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
|
||||||
|
COLOUR_BITRATE_OK = "#dcedc8"
|
||||||
|
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
|
||||||
|
COLOUR_CURRENT_TAB = "#248f24"
|
||||||
COLOUR_ENDING_TIMER = "#dc3545"
|
COLOUR_ENDING_TIMER = "#dc3545"
|
||||||
COLOUR_EVEN_PLAYLIST = "#d9d9d9"
|
COLOUR_EVEN_PLAYLIST = "#d9d9d9"
|
||||||
COLOUR_NEXT_HEADER = "#fff3cd"
|
COLOUR_LABEL_TEXT = "#000000"
|
||||||
|
COLOUR_LONG_START = "#dc3545"
|
||||||
COLOUR_NEXT_PLAYLIST = "#ffc107"
|
COLOUR_NEXT_PLAYLIST = "#ffc107"
|
||||||
|
COLOUR_NEXT_TAB = "#b38600"
|
||||||
|
COLOUR_NORMAL_TAB = "#000000"
|
||||||
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
||||||
COLOUR_PREVIOUS_HEADER = "#f8d7da"
|
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
||||||
|
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
|
||||||
|
COLOUR_UNREADABLE = "#dc3545"
|
||||||
COLOUR_WARNING_TIMER = "#ffc107"
|
COLOUR_WARNING_TIMER = "#ffc107"
|
||||||
DBFS_FADE = -12
|
|
||||||
DBFS_SILENCE = -50
|
DBFS_SILENCE = -50
|
||||||
|
DEFAULT_COLUMN_WIDTH = 200
|
||||||
DISPLAY_SQL = False
|
DISPLAY_SQL = False
|
||||||
ERRORS_TO = ['kae@midnighthax.com']
|
DO_NOT_IMPORT = "Do not import"
|
||||||
FADE_STEPS = 20
|
ENGINE_OPTIONS = dict(pool_pre_ping=True)
|
||||||
FADE_TIME = 3000
|
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
|
||||||
LOG_LEVEL_STDERR = logging.DEBUG
|
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"
|
||||||
|
LAST_PLAYED_TOOLTIP_DATE_FORMAT = "%a, %d %b %Y"
|
||||||
|
LOG_LEVEL_STDERR = logging.INFO
|
||||||
LOG_LEVEL_SYSLOG = logging.DEBUG
|
LOG_LEVEL_SYSLOG = logging.DEBUG
|
||||||
LOG_NAME = "musicmuster"
|
LOG_NAME = "musicmuster"
|
||||||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
|
||||||
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
|
MAIL_PORT = int(os.environ.get("MAIL_PORT") or 25)
|
||||||
MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
|
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
|
||||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
|
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
|
||||||
|
MAIN_WINDOW_TITLE = "MusicMuster"
|
||||||
|
MAX_IMPORT_MATCHES = 5
|
||||||
|
MAX_IMPORT_THREADS = 3
|
||||||
|
MAX_INFO_TABS = 5
|
||||||
|
MAX_MISSING_FILES_TO_REPORT = 10
|
||||||
MILLISECOND_SIGFIGS = 0
|
MILLISECOND_SIGFIGS = 0
|
||||||
MYSQL_CONNECT = "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster"
|
MINIMUM_ROW_HEIGHT = 30
|
||||||
NORMALISE_ON_IMPORT = True
|
NO_QUERY_NAME = "Select query"
|
||||||
ROOT = "/home/kae/music"
|
NO_TEMPLATE_NAME = "None"
|
||||||
TESTMODE = True
|
NOTE_TIME_FORMAT = "%H:%M"
|
||||||
TIMER_MS = 500
|
OBS_HOST = "localhost"
|
||||||
VOLUME_VLC_DEFAULT = 81
|
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
|
||||||
|
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"
|
||||||
|
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||||
|
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
|
||||||
config = Config
|
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)
|
||||||
|
|||||||
31
app/dbmanager.py
Normal 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
@ -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
@ -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
@ -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()
|
||||||
442
app/helpers.py
@ -1,6 +1,298 @@
|
|||||||
def ms_to_mmss(ms, decimals=0, negative=False):
|
# Standard library imports
|
||||||
|
import datetime as dt
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# PyQt imports
|
||||||
|
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
import filetype
|
||||||
|
from mutagen.flac import FLAC # type: ignore
|
||||||
|
from mutagen.mp3 import MP3 # type: ignore
|
||||||
|
from pydub import AudioSegment, effects
|
||||||
|
from pydub.utils import mediainfo
|
||||||
|
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from classes import AudioMetadata, ApplicationError, Tags
|
||||||
|
from config import Config
|
||||||
|
from log import log
|
||||||
|
from models import Tracks
|
||||||
|
|
||||||
|
start_time_re = re.compile(r"@\d\d:\d\d")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_yes_no(
|
||||||
|
title: str,
|
||||||
|
question: str,
|
||||||
|
default_yes: bool = False,
|
||||||
|
parent: Optional[QMainWindow] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Ask question; return True for yes, False for no"""
|
||||||
|
|
||||||
|
dlg = QMessageBox(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 == 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:
|
||||||
|
"""
|
||||||
|
Returns the millisecond/index of the point where the volume drops below
|
||||||
|
the maximum and doesn't get louder again.
|
||||||
|
audio_segment - the sdlg_search_database_uiegment to find silence in
|
||||||
|
fade_threshold - the upper bound for how quiet is silent in dFBS
|
||||||
|
chunk_size - chunk size for interating over the segment in ms
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert chunk_size > 0 # to avoid infinite loop
|
||||||
|
|
||||||
|
segment_length: int = audio_segment.duration_seconds * 1000 # ms
|
||||||
|
trim_ms = segment_length - chunk_size
|
||||||
|
max_vol = audio_segment.dBFS
|
||||||
|
if fade_threshold == 0:
|
||||||
|
fade_threshold = max_vol
|
||||||
|
|
||||||
|
while (
|
||||||
|
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
|
||||||
|
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
|
||||||
|
return int(trim_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def file_is_unreadable(path: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if passed path is readable, else False
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"):
|
||||||
|
return AudioSegment.from_mp3(path)
|
||||||
|
elif path.endswith(".flac"):
|
||||||
|
return AudioSegment.from_file(path, "flac") # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedded_time(text: str) -> Optional[dt.datetime]:
|
||||||
|
"""Return datetime specified as @hh:mm in text"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
match = start_time_re.search(text)
|
||||||
|
except TypeError:
|
||||||
|
return None
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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_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.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
@past_date: datetime
|
||||||
|
@reference_date: datetime, defaults to current date and time
|
||||||
|
|
||||||
|
@return: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not past_date or past_date == Config.EPOCH:
|
||||||
|
return "Never"
|
||||||
|
if not reference_date:
|
||||||
|
reference_date = dt.datetime.now()
|
||||||
|
|
||||||
|
# Check parameters
|
||||||
|
if past_date > reference_date:
|
||||||
|
return "get_relative_date() past_date is after relative_date"
|
||||||
|
|
||||||
|
days: int
|
||||||
|
days_str: str
|
||||||
|
weeks: int
|
||||||
|
weeks_str: str
|
||||||
|
|
||||||
|
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
|
||||||
|
if weeks == days == 0:
|
||||||
|
# Same day so return time instead
|
||||||
|
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
|
||||||
|
if weeks == 1:
|
||||||
|
weeks_str = "week"
|
||||||
|
else:
|
||||||
|
weeks_str = "weeks"
|
||||||
|
if days == 1:
|
||||||
|
days_str = "day"
|
||||||
|
else:
|
||||||
|
days_str = "days"
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
Returns the millisecond/index that the leading silence ends.
|
||||||
|
audio_segment - the segment to find silence in
|
||||||
|
silence_threshold - the upper bound for how quiet is silent in dFBS
|
||||||
|
chunk_size - chunk size for interating over the segment in ms
|
||||||
|
|
||||||
|
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
trim_ms: int = 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( # 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 ms_to_mmss(
|
||||||
|
ms: Optional[int],
|
||||||
|
decimals: int = 0,
|
||||||
|
negative: bool = False,
|
||||||
|
none: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Convert milliseconds to mm:ss"""
|
||||||
|
|
||||||
|
minutes: int
|
||||||
|
remainder: int
|
||||||
|
seconds: float
|
||||||
|
|
||||||
if not ms:
|
if not ms:
|
||||||
return "-"
|
if none:
|
||||||
|
return none
|
||||||
|
else:
|
||||||
|
return "-"
|
||||||
sign = ""
|
sign = ""
|
||||||
if ms < 0:
|
if ms < 0:
|
||||||
if negative:
|
if negative:
|
||||||
@ -17,3 +309,149 @@ def ms_to_mmss(ms, decimals=0, negative=False):
|
|||||||
seconds = 59.0
|
seconds = 59.0
|
||||||
|
|
||||||
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
|
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_track(path: str) -> None:
|
||||||
|
"""Normalise track"""
|
||||||
|
|
||||||
|
# Check type
|
||||||
|
ftype = os.path.splitext(path)[1][1:]
|
||||||
|
if ftype not in ["mp3", "flac"]:
|
||||||
|
log.error(
|
||||||
|
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
|
||||||
|
)
|
||||||
|
|
||||||
|
bitrate = mediainfo(path)["bit_rate"]
|
||||||
|
audio = get_audio_segment(path)
|
||||||
|
if not audio:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current file gid, uid and permissions
|
||||||
|
stats = os.stat(path)
|
||||||
|
try:
|
||||||
|
# Copy original file
|
||||||
|
_, temp_path = tempfile.mkstemp()
|
||||||
|
shutil.copyfile(path, temp_path)
|
||||||
|
except Exception as 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)
|
||||||
|
# Fix up permssions and ownership
|
||||||
|
os.chown(path, stats.st_uid, stats.st_gid)
|
||||||
|
os.chmod(path, stats.st_mode)
|
||||||
|
# Copy tags
|
||||||
|
tag_handler: type[FLAC | MP3]
|
||||||
|
if ftype == "flac":
|
||||||
|
tag_handler = FLAC
|
||||||
|
elif ftype == "mp3":
|
||||||
|
tag_handler = MP3
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
src = tag_handler(temp_path)
|
||||||
|
dst = tag_handler(path)
|
||||||
|
for tag in src:
|
||||||
|
dst[tag] = src[tag]
|
||||||
|
dst.save()
|
||||||
|
except Exception as err:
|
||||||
|
log.debug(f"helpers.normalise_track({path}): err2: {repr(err)}")
|
||||||
|
# Restore original file
|
||||||
|
shutil.copyfile(path, temp_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove all instances of substring from parent string, case insensitively
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Convert both strings to lowercase for case-insensitive comparison
|
||||||
|
lower_parent = parent_string.lower()
|
||||||
|
lower_substring = substring.lower()
|
||||||
|
|
||||||
|
# Initialize the result string
|
||||||
|
result = parent_string
|
||||||
|
|
||||||
|
# Continue removing the substring until it's no longer found
|
||||||
|
while lower_substring in lower_parent:
|
||||||
|
# Find the index of the substring
|
||||||
|
index = lower_parent.find(lower_substring)
|
||||||
|
|
||||||
|
# Remove the substring
|
||||||
|
result = result[:index] + result[index + len(substring) :]
|
||||||
|
|
||||||
|
# Update the lowercase versions
|
||||||
|
lower_parent = result.lower()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
|
||||||
|
# From https://docs.python.org/3/library/email.examples.html
|
||||||
|
|
||||||
|
# Create a text/plain message
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
msg["Subject"] = subj
|
||||||
|
msg["From"] = from_addr
|
||||||
|
msg["To"] = to_addr
|
||||||
|
|
||||||
|
# Send the message via SMTP server.
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
try:
|
||||||
|
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
|
||||||
|
if Config.MAIL_USE_TLS:
|
||||||
|
s.starttls(context=context)
|
||||||
|
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
|
||||||
|
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
|
||||||
|
s.send_message(msg)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
finally:
|
||||||
|
s.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def set_track_metadata(track: Tracks) -> None:
|
||||||
|
"""Set/update track metadata in database"""
|
||||||
|
|
||||||
|
audio_metadata = get_audio_metadata(track.path)
|
||||||
|
tags = get_tags(track.path)
|
||||||
|
|
||||||
|
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, parent: Optional[QWidget] = None) -> None:
|
||||||
|
"""Display a message to user"""
|
||||||
|
|
||||||
|
dlg = QMessageBox(parent)
|
||||||
|
dlg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
dlg.setWindowTitle(title)
|
||||||
|
dlg.setText(msg)
|
||||||
|
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||||
|
|
||||||
|
_ = dlg.exec()
|
||||||
|
|
||||||
|
|
||||||
|
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
|
||||||
|
"""Display a warning to user"""
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Return fade point from start in milliseconds"""
|
||||||
|
|
||||||
|
return fade_point(audio_segment, silence_threshold, chunk_size)
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
ui/icons_rc.py
|
|
||||||
56
app/jittermonitor.py
Normal 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,
|
||||||
|
)
|
||||||
184
app/log.py
@ -1,96 +1,138 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/env python3
|
||||||
|
# Standard library imports
|
||||||
|
from collections import defaultdict
|
||||||
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
|
import logging.config
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
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 config import Config
|
||||||
|
from classes import ApplicationError
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionFilter(logging.Filter):
|
||||||
|
"""Filter to allow category-based logging to stderr."""
|
||||||
|
|
||||||
|
def __init__(self, module_functions: dict[str, list[str]]):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.modules: list[str] = []
|
||||||
|
self.functions: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
if module_functions:
|
||||||
|
for module in module_functions.keys():
|
||||||
|
if module_functions[module]:
|
||||||
|
for function in module_functions[module]:
|
||||||
|
self.functions[module].append(function)
|
||||||
|
else:
|
||||||
|
self.modules.append(module)
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
if not getattr(record, "levelname", None) == "DEBUG":
|
||||||
|
# Only prcess DEBUG messages
|
||||||
|
return False
|
||||||
|
|
||||||
|
module = getattr(record, "module", None)
|
||||||
|
if not module:
|
||||||
|
# No module in record
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Process if this is a module we're tracking
|
||||||
|
if module in self.modules:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Process if this is a function we're tracking
|
||||||
|
if getattr(record, "funcName", None) in self.functions[module]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class LevelTagFilter(logging.Filter):
|
class LevelTagFilter(logging.Filter):
|
||||||
"Add leveltag"
|
"""Add leveltag"""
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
# Extract the first character of the level name
|
# Extract the first character of the level name
|
||||||
record.leveltag = record.levelname[0]
|
record.leveltag = record.levelname[0]
|
||||||
|
# We never actually filter messages out, just add an extra field
|
||||||
# We never actually filter messages out, just abuse filtering to add an
|
# to the LogRecord
|
||||||
# extra field to the LogRecord
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Load YAML logging configuration
|
||||||
|
with open("app/logging.yaml", "r") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
logging.config.dictConfig(config)
|
||||||
|
|
||||||
|
# Get logger
|
||||||
log = logging.getLogger(Config.LOG_NAME)
|
log = logging.getLogger(Config.LOG_NAME)
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
filter = LevelTagFilter()
|
|
||||||
syslog.addFilter(filter)
|
|
||||||
stderr.addFilter(filter)
|
|
||||||
|
|
||||||
# create formatter and add it to the handlers
|
|
||||||
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
|
|
||||||
datefmt='%H:%M:%S')
|
|
||||||
syslog_fmt = logging.Formatter('[%(name)s] %(leveltag)s: %(message)s')
|
|
||||||
stderr.setFormatter(stderr_fmt)
|
|
||||||
syslog.setFormatter(syslog_fmt)
|
|
||||||
|
|
||||||
# add the handlers to the log
|
|
||||||
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("\033[1;31;47m")
|
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
|
||||||
logging.critical(''.join(traceback.format_tb(tb)))
|
|
||||||
print("\033[1;37;40m")
|
msg = stackprinter.format(exc_value)
|
||||||
logging.critical('{0}: {1}'.format(ex_cls, ex))
|
log.error(msg)
|
||||||
|
log.error(error_msg)
|
||||||
|
print("Critical error:", error_msg) # Consider logging instead of print
|
||||||
|
|
||||||
|
if os.environ["MM_ENV"] == "PRODUCTION":
|
||||||
|
from helpers import send_mail
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
Config.ERRORS_TO,
|
||||||
|
Config.ERRORS_FROM,
|
||||||
|
"Exception (log_uncaught_exceptions) from musicmuster",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
if QApplication.instance() is not None:
|
||||||
|
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
|
||||||
|
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
|
||||||
|
QMessageBox.critical(None, "Application Error", msg)
|
||||||
|
|
||||||
|
|
||||||
sys.excepthook = log_uncaught_exceptions
|
def truncate_large(obj, limit=5):
|
||||||
|
"""Helper to truncate large lists or other iterables."""
|
||||||
|
if isinstance(obj, (list, tuple, set)):
|
||||||
|
if len(obj) > limit:
|
||||||
|
return f"{type(obj).__name__}(len={len(obj)}, items={list(obj)[:limit]}...)"
|
||||||
|
|
||||||
|
return repr(obj)
|
||||||
|
|
||||||
|
|
||||||
def DEBUG(msg):
|
def log_call(func):
|
||||||
log.debug(msg)
|
@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
|
||||||
|
|
||||||
|
|
||||||
def EXCEPTION(msg):
|
sys.excepthook = handle_exception
|
||||||
log.exception(msg, exc_info=True, stack_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def ERROR(msg):
|
|
||||||
log.error(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def INFO(msg):
|
|
||||||
log.info(msg)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
DEBUG("hi debug")
|
|
||||||
ERROR("hi error")
|
|
||||||
INFO("hi info")
|
|
||||||
EXCEPTION("hi exception")
|
|
||||||
|
|
||||||
def f():
|
|
||||||
return g()
|
|
||||||
|
|
||||||
def g():
|
|
||||||
return h()
|
|
||||||
|
|
||||||
def h():
|
|
||||||
return i()
|
|
||||||
|
|
||||||
def i():
|
|
||||||
1 / 0
|
|
||||||
|
|
||||||
f()
|
|
||||||
|
|||||||
55
app/logging.yaml
Normal 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
@ -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
@ -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"
|
||||||
|
|
||||||
469
app/model.py
@ -1,469 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean,
|
|
||||||
Column,
|
|
||||||
DateTime,
|
|
||||||
Float,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
func
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
|
|
||||||
from sqlalchemy.orm import relationship, sessionmaker
|
|
||||||
|
|
||||||
from config import Config
|
|
||||||
from log import DEBUG, ERROR
|
|
||||||
|
|
||||||
# Create session at the global level as per
|
|
||||||
# https://docs.sqlalchemy.org/en/13/orm/session_basics.html
|
|
||||||
|
|
||||||
# Set up database connection
|
|
||||||
engine = sqlalchemy.create_engine(f"{Config.MYSQL_CONNECT}?charset=utf8",
|
|
||||||
encoding='utf-8',
|
|
||||||
echo=Config.DISPLAY_SQL,
|
|
||||||
pool_pre_ping=True)
|
|
||||||
Base = declarative_base()
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
# Create a Session factory
|
|
||||||
Session = sessionmaker(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
# Database classes
|
|
||||||
class Notes(Base):
|
|
||||||
__tablename__ = 'notes'
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
playlist_id = Column(Integer, ForeignKey('playlists.id'))
|
|
||||||
playlist = relationship("Playlists", back_populates="notes")
|
|
||||||
row = Column(Integer, nullable=False)
|
|
||||||
note = Column(String(256), index=False)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return (
|
|
||||||
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def add_note(session, playlist_id, row, text):
|
|
||||||
DEBUG(f"add_note(playlist_id={playlist_id}, row={row}, text={text})")
|
|
||||||
note = Notes()
|
|
||||||
note.playlist_id = playlist_id
|
|
||||||
note.row = row
|
|
||||||
note.note = text
|
|
||||||
session.add(note)
|
|
||||||
session.commit()
|
|
||||||
return note
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def delete_note(session, id):
|
|
||||||
DEBUG(f"delete_note(id={id}")
|
|
||||||
|
|
||||||
session.query(Notes).filter(Notes.id == id).delete()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Not currently used 1 June 2021
|
|
||||||
# @staticmethod
|
|
||||||
# def get_note(session, id):
|
|
||||||
# return session.query(Notes).filter(Notes.id == id).one()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_note(cls, session, id, row, text=None):
|
|
||||||
"""
|
|
||||||
Update note details. If text=None, don't change text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEBUG(f"update_note(id={id}, row={row}, text={text})")
|
|
||||||
|
|
||||||
note = session.query(cls).filter(cls.id == id).one()
|
|
||||||
note.row = row
|
|
||||||
if text:
|
|
||||||
note.note = text
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class Playdates(Base):
|
|
||||||
__tablename__ = 'playdates'
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
lastplayed = Column(DateTime, index=True, default=None)
|
|
||||||
track_id = Column(Integer, ForeignKey('tracks.id'))
|
|
||||||
tracks = relationship("Tracks", back_populates="playdates")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def add_playdate(session, track):
|
|
||||||
DEBUG(f"add_playdate(track={track})")
|
|
||||||
pd = Playdates()
|
|
||||||
pd.lastplayed = datetime.now()
|
|
||||||
pd.track_id = track.id
|
|
||||||
session.add(pd)
|
|
||||||
track.update_lastplayed()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class Playlists(Base):
|
|
||||||
"""
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
pl = session.query(Playlists).filter(Playlists.id == 1).one()
|
|
||||||
|
|
||||||
pl
|
|
||||||
<Playlist(id=1, name=Default>
|
|
||||||
|
|
||||||
pl.tracks
|
|
||||||
[<__main__.PlaylistTracks at 0x7fcd20181c18>,
|
|
||||||
<__main__.PlaylistTracks at 0x7fcd20181c88>,
|
|
||||||
<__main__.PlaylistTracks at 0x7fcd20181be0>,
|
|
||||||
<__main__.PlaylistTracks at 0x7fcd20181c50>]
|
|
||||||
|
|
||||||
[a.tracks for a in pl.tracks]
|
|
||||||
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
|
|
||||||
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
|
|
||||||
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
|
|
||||||
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]]
|
|
||||||
|
|
||||||
glue = PlaylistTracks(row=5)
|
|
||||||
|
|
||||||
tr = session.query(Tracks).filter(Tracks.id == 676).one()
|
|
||||||
|
|
||||||
tr
|
|
||||||
<Track(id=676, title=Seven Nation Army, artist=White Stripes,
|
|
||||||
path=/home/kae/music/White Stripes/Elephant/01. Seven Nation Army.flac>
|
|
||||||
|
|
||||||
glue.track_id = tr.id
|
|
||||||
|
|
||||||
pl.tracks.append(glue)
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
[a.tracks for a in pl.tracks]
|
|
||||||
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
|
|
||||||
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
|
|
||||||
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
|
|
||||||
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]
|
|
||||||
<Track(id=676, title=Seven Nation Army, artist=White Stripes[...]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "playlists"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name = Column(String(32), nullable=False, unique=True)
|
|
||||||
last_used = Column(DateTime, default=None, nullable=True)
|
|
||||||
loaded = Column(Boolean, default=True)
|
|
||||||
notes = relationship("Notes",
|
|
||||||
order_by="Notes.row",
|
|
||||||
back_populates="playlist")
|
|
||||||
tracks = relationship("PlaylistTracks",
|
|
||||||
order_by="PlaylistTracks.row",
|
|
||||||
back_populates="playlists")
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return (f"<Playlist(id={self.id}, name={self.name}>")
|
|
||||||
|
|
||||||
def add_track(self, session, track, row=None):
|
|
||||||
"""
|
|
||||||
Add track to playlist at given row.
|
|
||||||
If row=None, add to end of playlist
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
row = PlaylistTracks.new_row(session, self.id)
|
|
||||||
|
|
||||||
glue = PlaylistTracks(row=row)
|
|
||||||
glue.track_id = track.id
|
|
||||||
self.tracks.append(glue)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def close(self, session):
|
|
||||||
"Record playlist as no longer loaded"
|
|
||||||
|
|
||||||
self.loaded = False
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_all_closed_playlists(session):
|
|
||||||
"Returns a list of all playlists not currently open"
|
|
||||||
|
|
||||||
return (
|
|
||||||
session.query(Playlists)
|
|
||||||
.filter(
|
|
||||||
(Playlists.loaded == False) | # noqa E712
|
|
||||||
(Playlists.loaded == None)
|
|
||||||
)
|
|
||||||
.order_by(Playlists.last_used.desc())
|
|
||||||
).all()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_all_playlists(session):
|
|
||||||
"Returns a list of all playlists"
|
|
||||||
|
|
||||||
return session.query(Playlists).all()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_last_used(session):
|
|
||||||
"""
|
|
||||||
Return a list of playlists marked "loaded", ordered by loaded date.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return (
|
|
||||||
session.query(Playlists)
|
|
||||||
.filter(Playlists.loaded == True) # noqa E712
|
|
||||||
.order_by(Playlists.last_used.desc())
|
|
||||||
).all()
|
|
||||||
|
|
||||||
def get_notes(self):
|
|
||||||
return [a.note for a in self.notes]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_playlist(session, playlist_id):
|
|
||||||
return (
|
|
||||||
session.query(Playlists)
|
|
||||||
.filter(
|
|
||||||
Playlists.id == playlist_id # noqa E712
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
|
|
||||||
def get_tracks(self):
|
|
||||||
return [a.tracks for a in self.tracks]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def new(session, name):
|
|
||||||
DEBUG(f"Playlists.new(name={name})")
|
|
||||||
playlist = Playlists()
|
|
||||||
playlist.name = name
|
|
||||||
session.add(playlist)
|
|
||||||
session.commit()
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def open(session, plid):
|
|
||||||
"Record playlist as loaded and used now"
|
|
||||||
|
|
||||||
p = session.query(Playlists).filter(Playlists.id == plid).one()
|
|
||||||
p.loaded = True
|
|
||||||
p.last_used = datetime.now()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTracks(Base):
|
|
||||||
__tablename__ = 'playlisttracks'
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
playlist_id = Column(Integer, ForeignKey('playlists.id'), primary_key=True)
|
|
||||||
track_id = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
|
|
||||||
row = Column(Integer, nullable=False)
|
|
||||||
tracks = relationship("Tracks", back_populates="playlists")
|
|
||||||
playlists = relationship("Playlists", back_populates="tracks")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def add_track(session, playlist_id, track_id, row):
|
|
||||||
DEBUG(
|
|
||||||
f"PlaylistTracks.add_track(playlist_id={playlist_id}, "
|
|
||||||
f"track_id={track_id}, row={row})"
|
|
||||||
)
|
|
||||||
plt = PlaylistTracks()
|
|
||||||
plt.playlist_id = playlist_id,
|
|
||||||
plt.track_id = track_id,
|
|
||||||
plt.row = row
|
|
||||||
session.add(plt)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def move_track(session, from_playlist_id, row, to_playlist_id):
|
|
||||||
DEBUG(
|
|
||||||
"PlaylistTracks.move_tracks(from_playlist_id="
|
|
||||||
f"{from_playlist_id}, row={row}, "
|
|
||||||
f"to_playlist_id={to_playlist_id})"
|
|
||||||
)
|
|
||||||
new_row = (
|
|
||||||
session.query(func.max(PlaylistTracks.row)).filter(
|
|
||||||
PlaylistTracks.playlist_id == to_playlist_id).scalar()
|
|
||||||
) + 1
|
|
||||||
record = session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == from_playlist_id,
|
|
||||||
PlaylistTracks.row == row
|
|
||||||
).one()
|
|
||||||
record.playlist_id = to_playlist_id
|
|
||||||
record.row = new_row
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def new_row(session, playlist_id):
|
|
||||||
"Return row number > largest existing row number"
|
|
||||||
|
|
||||||
last_row = session.query(func.max(PlaylistTracks.row)).one()[0]
|
|
||||||
return last_row + 1
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def remove_all_tracks(session, playlist_id):
|
|
||||||
"""
|
|
||||||
Remove all tracks from passed playlist_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == playlist_id,
|
|
||||||
).delete()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def remove_track(session, playlist_id, row):
|
|
||||||
DEBUG(
|
|
||||||
f"PlaylistTracks.remove_track(playlist_id={playlist_id}, "
|
|
||||||
f"row={row})"
|
|
||||||
)
|
|
||||||
session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == playlist_id,
|
|
||||||
PlaylistTracks.row == row
|
|
||||||
).delete()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_row_track(session, playlist_id, row, track_id):
|
|
||||||
DEBUG(
|
|
||||||
f"PlaylistTracks.update_track_row(playlist_id={playlist_id}, "
|
|
||||||
f"row={row}, track_id={track_id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plt = session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == playlist_id,
|
|
||||||
PlaylistTracks.row == row
|
|
||||||
).one()
|
|
||||||
except MultipleResultsFound:
|
|
||||||
ERROR(
|
|
||||||
f"Multiple rows matched in query: "
|
|
||||||
f"PlaylistTracks.playlist_id == {playlist_id}, "
|
|
||||||
f"PlaylistTracks.row == {row}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except NoResultFound:
|
|
||||||
ERROR(
|
|
||||||
f"No rows matched in query: "
|
|
||||||
f"PlaylistTracks.playlist_id == {playlist_id}, "
|
|
||||||
f"PlaylistTracks.row == {row}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
plt.track_id = track_id
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(Base):
|
|
||||||
__tablename__ = 'settings'
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name = Column(String(32), nullable=False, unique=True)
|
|
||||||
f_datetime = Column(DateTime, default=None, nullable=True)
|
|
||||||
f_int = Column(Integer, default=None, nullable=True)
|
|
||||||
f_string = Column(String(128), default=None, nullable=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_int(cls, session, name):
|
|
||||||
try:
|
|
||||||
int_setting = session.query(cls).filter(
|
|
||||||
cls.name == name).one()
|
|
||||||
except NoResultFound:
|
|
||||||
int_setting = Settings()
|
|
||||||
int_setting.name = name
|
|
||||||
int_setting.f_int = None
|
|
||||||
session.add(int_setting)
|
|
||||||
session.commit()
|
|
||||||
return int_setting
|
|
||||||
|
|
||||||
def update(self, session, data):
|
|
||||||
for key, value in data.items():
|
|
||||||
assert hasattr(self, key)
|
|
||||||
setattr(self, key, value)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class Tracks(Base):
|
|
||||||
__tablename__ = 'tracks'
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
title = Column(String(256), index=True)
|
|
||||||
artist = Column(String(256), index=True)
|
|
||||||
duration = Column(Integer, index=True)
|
|
||||||
start_gap = Column(Integer, index=False)
|
|
||||||
fade_at = Column(Integer, index=False)
|
|
||||||
silence_at = Column(Integer, index=False)
|
|
||||||
path = Column(String(2048), index=False, nullable=False)
|
|
||||||
mtime = Column(Float, index=True)
|
|
||||||
lastplayed = Column(DateTime, index=True, default=None)
|
|
||||||
playlists = relationship("PlaylistTracks", back_populates="tracks")
|
|
||||||
playdates = relationship("Playdates", back_populates="tracks")
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return (
|
|
||||||
f"<Track(id={self.id}, title={self.title}, "
|
|
||||||
f"artist={self.artist}, path={self.path}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_or_create(cls, session, path):
|
|
||||||
DEBUG(f"Tracks.get_or_create(path={path})")
|
|
||||||
try:
|
|
||||||
track = session.query(cls).filter(cls.path == path).one()
|
|
||||||
except NoResultFound:
|
|
||||||
track = Tracks()
|
|
||||||
track.path = path
|
|
||||||
session.add(track)
|
|
||||||
return track
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_duration(session, id):
|
|
||||||
try:
|
|
||||||
return session.query(
|
|
||||||
Tracks.duration).filter(Tracks.id == id).one()[0]
|
|
||||||
except NoResultFound:
|
|
||||||
ERROR(f"Can't find track id {id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_all_paths(session):
|
|
||||||
"Return a list of paths of all tracks"
|
|
||||||
|
|
||||||
return [a[0] for a in session.query(Tracks.path).all()]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_path(session, id):
|
|
||||||
try:
|
|
||||||
return session.query(Tracks.path).filter(Tracks.id == id).one()[0]
|
|
||||||
except NoResultFound:
|
|
||||||
ERROR(f"Can't find track id {id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_track(session, id):
|
|
||||||
try:
|
|
||||||
DEBUG(f"Tracks.get_track(track_id={id})")
|
|
||||||
track = session.query(Tracks).filter(Tracks.id == id).one()
|
|
||||||
return track
|
|
||||||
except NoResultFound:
|
|
||||||
ERROR(f"get_track({id}): not found")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def search_titles(session, text):
|
|
||||||
return (
|
|
||||||
session.query(Tracks)
|
|
||||||
.filter(Tracks.title.ilike(f"%{text}%"))
|
|
||||||
.order_by(Tracks.title)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def track_from_id(session, id):
|
|
||||||
return session.query(Tracks).filter(
|
|
||||||
Tracks.id == id).one()
|
|
||||||
|
|
||||||
def update_lastplayed(self):
|
|
||||||
self.lastplayed = datetime.now()
|
|
||||||
873
app/models.py
Normal file
@ -0,0 +1,873 @@
|
|||||||
|
# Standard library imports
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# PyQt imports
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from dogpile.cache import make_region
|
||||||
|
from dogpile.cache.api import NO_VALUE
|
||||||
|
from sqlalchemy import (
|
||||||
|
bindparam,
|
||||||
|
delete,
|
||||||
|
func,
|
||||||
|
select,
|
||||||
|
text,
|
||||||
|
update,
|
||||||
|
)
|
||||||
|
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
||||||
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
from sqlalchemy.engine.row import RowMapping
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from classes import ApplicationError, Filter
|
||||||
|
from config import Config
|
||||||
|
from dbmanager import DatabaseManager
|
||||||
|
import dbtables
|
||||||
|
from log import log
|
||||||
|
|
||||||
|
|
||||||
|
# Establish database connection
|
||||||
|
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||||
|
if DATABASE_URL is None:
|
||||||
|
raise ValueError("DATABASE_URL is undefined")
|
||||||
|
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
||||||
|
raise ValueError("Unit tests running on non-Sqlite database")
|
||||||
|
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
|
||||||
|
|
||||||
|
# Configure the cache region
|
||||||
|
cache_region = make_region().configure(
|
||||||
|
'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed)
|
||||||
|
expiration_time=600 # Cache expires after 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
|
||||||
|
"""
|
||||||
|
Run a sql string and return results
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return session.execute(text(sql)).mappings().all()
|
||||||
|
except ProgrammingError as e:
|
||||||
|
raise ApplicationError(e)
|
||||||
|
|
||||||
|
|
||||||
|
# Database classes
|
||||||
|
class NoteColours(dbtables.NoteColoursTable):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
substring: str,
|
||||||
|
colour: str,
|
||||||
|
enabled: bool = True,
|
||||||
|
is_regex: bool = False,
|
||||||
|
is_casesensitive: bool = False,
|
||||||
|
order: Optional[int] = 0,
|
||||||
|
) -> None:
|
||||||
|
self.substring = substring
|
||||||
|
self.colour = colour
|
||||||
|
self.enabled = enabled
|
||||||
|
self.is_regex = is_regex
|
||||||
|
self.is_casesensitive = is_casesensitive
|
||||||
|
self.order = order
|
||||||
|
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, session: Session) -> Sequence["NoteColours"]:
|
||||||
|
"""
|
||||||
|
Return all records
|
||||||
|
"""
|
||||||
|
|
||||||
|
cache_key = "note_colours_all"
|
||||||
|
cached_result = cache_region.get(cache_key)
|
||||||
|
|
||||||
|
if cached_result is not NO_VALUE:
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
# Query the database
|
||||||
|
result = session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(cls.order)
|
||||||
|
).all()
|
||||||
|
cache_region.set(cache_key, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_colour(
|
||||||
|
session: Session, text: str, foreground: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Parse text and return background (foreground if foreground==True) colour
|
||||||
|
string if matched, else None
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
match = False
|
||||||
|
for rec in NoteColours.get_all(session):
|
||||||
|
if rec.is_regex:
|
||||||
|
flags = re.UNICODE
|
||||||
|
if not rec.is_casesensitive:
|
||||||
|
flags |= re.IGNORECASE
|
||||||
|
p = re.compile(rec.substring, flags)
|
||||||
|
if p.match(text):
|
||||||
|
match = True
|
||||||
|
else:
|
||||||
|
if rec.is_casesensitive:
|
||||||
|
if rec.substring in text:
|
||||||
|
match = True
|
||||||
|
else:
|
||||||
|
if rec.substring.lower() in text.lower():
|
||||||
|
match = True
|
||||||
|
|
||||||
|
if match:
|
||||||
|
if foreground:
|
||||||
|
return rec.foreground or ""
|
||||||
|
else:
|
||||||
|
return rec.colour
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
"""Invalidate dogpile cache"""
|
||||||
|
|
||||||
|
cache_region.delete("note_colours_all")
|
||||||
|
|
||||||
|
|
||||||
|
class Playdates(dbtables.PlaydatesTable):
|
||||||
|
def __init__(
|
||||||
|
self, session: Session, track_id: int, when: Optional[dt.datetime] = None
|
||||||
|
) -> None:
|
||||||
|
"""Record that track was played"""
|
||||||
|
|
||||||
|
if not when:
|
||||||
|
self.lastplayed = dt.datetime.now()
|
||||||
|
else:
|
||||||
|
self.lastplayed = when
|
||||||
|
self.track_id = track_id
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def last_playdates(
|
||||||
|
session: Session, track_id: int, limit: int = 5
|
||||||
|
) -> Sequence["Playdates"]:
|
||||||
|
"""
|
||||||
|
Return a list of the last limit playdates for this track, sorted
|
||||||
|
latest to earliest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
Playdates.select()
|
||||||
|
.where(Playdates.track_id == track_id)
|
||||||
|
.order_by(Playdates.lastplayed.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def last_played(session: Session, track_id: int) -> dt.datetime:
|
||||||
|
"""Return datetime track last played or None"""
|
||||||
|
|
||||||
|
last_played = session.execute(
|
||||||
|
select(Playdates.lastplayed)
|
||||||
|
.where(Playdates.track_id == track_id)
|
||||||
|
.order_by(Playdates.lastplayed.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if last_played:
|
||||||
|
return last_played[0]
|
||||||
|
else:
|
||||||
|
# Should never be reached as we create record with a
|
||||||
|
# last_played value
|
||||||
|
return Config.EPOCH # pragma: no cover
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]:
|
||||||
|
"""
|
||||||
|
Return a list of the last limit tracks played, sorted
|
||||||
|
earliest to latest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
Playdates.select().order_by(Playdates.lastplayed.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]:
|
||||||
|
"""Return a list of Playdates objects since passed time"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(Playdates)
|
||||||
|
.where(Playdates.lastplayed >= since)
|
||||||
|
.order_by(Playdates.lastplayed)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
class Playlists(dbtables.PlaylistsTable):
|
||||||
|
def __init__(self, session: Session, name: str, template_id: int) -> None:
|
||||||
|
"""Create playlist with passed name"""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.last_used = dt.datetime.now()
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# If a template is specified, copy from it
|
||||||
|
if template_id:
|
||||||
|
PlaylistRows.copy_playlist(session, template_id, self.id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
|
||||||
|
"""
|
||||||
|
Make all tab records NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self, session: Session) -> None:
|
||||||
|
"""Mark playlist as unloaded"""
|
||||||
|
|
||||||
|
self.open = False
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, session: Session) -> Sequence["Playlists"]:
|
||||||
|
"""Returns a list of all playlists ordered by last use"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.filter(cls.is_template.is_(False))
|
||||||
|
.order_by(cls.last_used.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||||
|
"""Returns a list of all templates ordered by name"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||||
|
"""Returns a list of favourite templates ordered by name"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.is_template.is_(True), cls.favourite.is_(True))
|
||||||
|
.order_by(cls.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
|
||||||
|
"""Returns a list of all closed playlists ordered by last use"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.filter(
|
||||||
|
cls.open.is_(False),
|
||||||
|
cls.is_template.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(cls.last_used.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
|
||||||
|
"""
|
||||||
|
Return a list of loaded playlists ordered by tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
def mark_open(self) -> None:
|
||||||
|
"""Mark playlist as loaded and used now"""
|
||||||
|
|
||||||
|
self.open = True
|
||||||
|
self.last_used = dt.datetime.now()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def name_is_available(session: Session, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if no playlist of this name exists else false.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.execute(select(Playlists).where(Playlists.name == name)).first()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
def rename(self, session: Session, new_name: str) -> None:
|
||||||
|
"""
|
||||||
|
Rename playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.name = new_name
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_as_template(
|
||||||
|
session: Session, playlist_id: int, template_name: str
|
||||||
|
) -> None:
|
||||||
|
"""Save passed playlist as new template"""
|
||||||
|
|
||||||
|
template = Playlists(session, template_name, template_id=0)
|
||||||
|
if not template or not template.id:
|
||||||
|
return
|
||||||
|
|
||||||
|
template.is_template = True
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
PlaylistRows.copy_playlist(session, playlist_id, template.id)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
playlist_id: int,
|
||||||
|
row_number: int,
|
||||||
|
note: str = "",
|
||||||
|
track_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create PlaylistRows object"""
|
||||||
|
|
||||||
|
self.playlist_id = playlist_id
|
||||||
|
self.track_id = track_id
|
||||||
|
self.row_number = row_number
|
||||||
|
self.note = note
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def append_note(self, extra_note: str) -> None:
|
||||||
|
"""Append passed note to any existing note"""
|
||||||
|
|
||||||
|
current_note = self.note
|
||||||
|
if current_note:
|
||||||
|
self.note = current_note + "\n" + extra_note
|
||||||
|
else:
|
||||||
|
self.note = extra_note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def copy_playlist(session: Session, src_id: int, dst_id: int) -> None:
|
||||||
|
"""Copy playlist entries"""
|
||||||
|
|
||||||
|
src_rows = session.scalars(
|
||||||
|
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for plr in src_rows:
|
||||||
|
PlaylistRows(
|
||||||
|
session=session,
|
||||||
|
playlist_id=dst_id,
|
||||||
|
row_number=plr.row_number,
|
||||||
|
note=plr.note,
|
||||||
|
track_id=plr.track_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deep_row(
|
||||||
|
cls, session: Session, playlist_id: int, row_number: int
|
||||||
|
) -> "PlaylistRows":
|
||||||
|
"""
|
||||||
|
Return a playlist row that includes full track and lastplayed data for
|
||||||
|
given playlist_id and row
|
||||||
|
"""
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(PlaylistRows)
|
||||||
|
.options(joinedload(cls.track))
|
||||||
|
.where(
|
||||||
|
PlaylistRows.playlist_id == playlist_id,
|
||||||
|
PlaylistRows.row_number == row_number,
|
||||||
|
)
|
||||||
|
# .options(joinedload(Tracks.playdates))
|
||||||
|
)
|
||||||
|
|
||||||
|
return session.execute(stmt).unique().scalar_one()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
|
||||||
|
"""
|
||||||
|
Delete rows in given playlist that have a higher row number
|
||||||
|
than 'maxrow'
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
delete(PlaylistRows).where(
|
||||||
|
PlaylistRows.playlist_id == playlist_id,
|
||||||
|
PlaylistRows.row_number > maxrow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_row(session: Session, playlist_id: int, row_number: int) -> None:
|
||||||
|
"""
|
||||||
|
Delete passed row in given playlist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
delete(PlaylistRows).where(
|
||||||
|
PlaylistRows.playlist_id == playlist_id,
|
||||||
|
PlaylistRows.row_number == row_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Ensure the row numbers for passed playlist have no gaps
|
||||||
|
"""
|
||||||
|
|
||||||
|
plrs = session.scalars(
|
||||||
|
select(PlaylistRows)
|
||||||
|
.where(PlaylistRows.playlist_id == playlist_id)
|
||||||
|
.order_by(PlaylistRows.row_number)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for i, plr in enumerate(plrs):
|
||||||
|
plr.row_number = i
|
||||||
|
|
||||||
|
# Ensure new row numbers are available to the caller
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def plrids_to_plrs(
|
||||||
|
cls, session: Session, playlist_id: int, plr_ids: list[int]
|
||||||
|
) -> Sequence["PlaylistRows"]:
|
||||||
|
"""
|
||||||
|
Take a list of PlaylistRows ids and return a list of corresponding
|
||||||
|
PlaylistRows objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
plrs = session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
|
||||||
|
.order_by(cls.row_number)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return plrs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
|
||||||
|
"""Return the last used row for playlist, or None if no rows"""
|
||||||
|
|
||||||
|
return session.execute(
|
||||||
|
select(func.max(PlaylistRows.row_number)).where(
|
||||||
|
PlaylistRows.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track_plr(
|
||||||
|
session: Session, track_id: int, playlist_id: int
|
||||||
|
) -> Optional["PlaylistRows"]:
|
||||||
|
"""Return first matching PlaylistRows object or None"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(PlaylistRows)
|
||||||
|
.where(
|
||||||
|
PlaylistRows.track_id == track_id,
|
||||||
|
PlaylistRows.playlist_id == playlist_id,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_played_rows(
|
||||||
|
cls, session: Session, playlist_id: int
|
||||||
|
) -> Sequence["PlaylistRows"]:
|
||||||
|
"""
|
||||||
|
For passed playlist, return a list of rows that
|
||||||
|
have been played.
|
||||||
|
"""
|
||||||
|
|
||||||
|
plrs = session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
|
||||||
|
.order_by(cls.row_number)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return plrs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_playlist_rows(
|
||||||
|
cls, session: Session, playlist_id: int
|
||||||
|
) -> Sequence["PlaylistRows"]:
|
||||||
|
"""
|
||||||
|
For passed playlist, return a list of rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(cls)
|
||||||
|
.where(cls.playlist_id == playlist_id)
|
||||||
|
.options(selectinload(cls.track))
|
||||||
|
.order_by(cls.row_number)
|
||||||
|
)
|
||||||
|
plrs = session.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
return plrs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_rows_with_tracks(
|
||||||
|
cls,
|
||||||
|
session: Session,
|
||||||
|
playlist_id: int,
|
||||||
|
) -> Sequence["PlaylistRows"]:
|
||||||
|
"""
|
||||||
|
For passed playlist, return a list of rows that
|
||||||
|
contain tracks
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = select(cls).where(
|
||||||
|
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
|
||||||
|
)
|
||||||
|
plrs = session.scalars((query).order_by(cls.row_number)).all()
|
||||||
|
|
||||||
|
return plrs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unplayed_rows(
|
||||||
|
cls, session: Session, playlist_id: int
|
||||||
|
) -> Sequence["PlaylistRows"]:
|
||||||
|
"""
|
||||||
|
For passed playlist, return a list of playlist rows that
|
||||||
|
have not been played.
|
||||||
|
"""
|
||||||
|
|
||||||
|
plrs = session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.playlist_id == playlist_id,
|
||||||
|
cls.track_id.is_not(None),
|
||||||
|
cls.played.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(cls.row_number)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return plrs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def insert_row(
|
||||||
|
cls,
|
||||||
|
session: Session,
|
||||||
|
playlist_id: int,
|
||||||
|
new_row_number: int,
|
||||||
|
note: str = "",
|
||||||
|
track_id: Optional[int] = None,
|
||||||
|
) -> "PlaylistRows":
|
||||||
|
cls.move_rows_down(session, playlist_id, new_row_number, 1)
|
||||||
|
return cls(
|
||||||
|
session,
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
row_number=new_row_number,
|
||||||
|
note=note,
|
||||||
|
track_id=track_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def move_rows_down(
|
||||||
|
session: Session, playlist_id: int, starting_row: int, move_by: int
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Create space to insert move_by additional rows by incremented row
|
||||||
|
number from starting_row to end of playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
log.debug(f"(move_rows_down({playlist_id=}, {starting_row=}, {move_by=}")
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
update(PlaylistRows)
|
||||||
|
.where(
|
||||||
|
(PlaylistRows.playlist_id == playlist_id),
|
||||||
|
(PlaylistRows.row_number >= starting_row),
|
||||||
|
)
|
||||||
|
.values(row_number=PlaylistRows.row_number + move_by)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_plr_row_numbers(
|
||||||
|
session: Session,
|
||||||
|
playlist_id: int,
|
||||||
|
sqla_map: list[dict[str, int]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Take a {plrid: row_number} dictionary and update the row numbers accordingly
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Update database. Ref:
|
||||||
|
# https://docs.sqlalchemy.org/en/20/tutorial/data_update.html#the-update-sql-expression-construct
|
||||||
|
stmt = (
|
||||||
|
update(PlaylistRows)
|
||||||
|
.where(
|
||||||
|
PlaylistRows.playlist_id == playlist_id,
|
||||||
|
PlaylistRows.id == bindparam("playlistrow_id"),
|
||||||
|
)
|
||||||
|
.values(row_number=bindparam("row_number"))
|
||||||
|
)
|
||||||
|
session.connection().execute(stmt, sqla_map)
|
||||||
|
|
||||||
|
|
||||||
|
class Queries(dbtables.QueriesTable):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
name: str,
|
||||||
|
filter: dbtables.Filter,
|
||||||
|
favourite: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Create new query"""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.filter = filter
|
||||||
|
self.favourite = favourite
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, session: Session) -> Sequence["Queries"]:
|
||||||
|
"""Returns a list of all queries ordered by name"""
|
||||||
|
|
||||||
|
return session.scalars(select(cls).order_by(cls.name)).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_favourites(cls, session: Session) -> Sequence["Queries"]:
|
||||||
|
"""Returns a list of favourite queries ordered by name"""
|
||||||
|
|
||||||
|
return session.scalars(
|
||||||
|
select(cls).where(cls.favourite.is_(True)).order_by(cls.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(dbtables.SettingsTable):
|
||||||
|
def __init__(self, session: Session, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_setting(cls, session: Session, name: str) -> "Settings":
|
||||||
|
"""Get existing setting or return new setting record"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return session.execute(select(cls).where(cls.name == name)).scalar_one()
|
||||||
|
|
||||||
|
except NoResultFound:
|
||||||
|
return Settings(session, name)
|
||||||
|
|
||||||
|
|
||||||
|
class Tracks(dbtables.TracksTable):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
path: str,
|
||||||
|
title: str,
|
||||||
|
artist: str,
|
||||||
|
duration: int,
|
||||||
|
start_gap: int,
|
||||||
|
fade_at: int,
|
||||||
|
silence_at: int,
|
||||||
|
bitrate: int,
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.bitrate = bitrate
|
||||||
|
self.duration = duration
|
||||||
|
self.start_gap = start_gap
|
||||||
|
self.fade_at = fade_at
|
||||||
|
self.silence_at = silence_at
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as error:
|
||||||
|
session.rollback()
|
||||||
|
log.error(f"Error ({error=}) importing track ({path=})")
|
||||||
|
raise ValueError(error)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, session: Session) -> Sequence["Tracks"]:
|
||||||
|
"""Return a list of all tracks"""
|
||||||
|
|
||||||
|
return session.scalars(select(cls)).unique().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
|
||||||
|
"""
|
||||||
|
Return a dictionary of all tracks, keyed by title
|
||||||
|
"""
|
||||||
|
|
||||||
|
result: dict[int, Tracks] = {}
|
||||||
|
|
||||||
|
for track in cls.get_all(session):
|
||||||
|
result[track.id] = track
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def exact_title_and_artist(
|
||||||
|
cls, session: Session, title: str, artist: str
|
||||||
|
) -> Sequence["Tracks"]:
|
||||||
|
"""
|
||||||
|
Search for exact but case-insensitive match of title and artist
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.title.ilike(title), cls.artist.ilike(artist))
|
||||||
|
.order_by(cls.title)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_filtered_tracks(
|
||||||
|
cls, session: Session, filter: Filter
|
||||||
|
) -> Sequence["Tracks"]:
|
||||||
|
"""
|
||||||
|
Return tracks matching filter
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = select(cls)
|
||||||
|
|
||||||
|
# Path specification
|
||||||
|
if filter.path:
|
||||||
|
if filter.path_type == "contains":
|
||||||
|
query = query.where(cls.path.ilike(f"%{filter.path}%"))
|
||||||
|
elif filter.path_type == "excluding":
|
||||||
|
query = query.where(cls.path.notilike(f"%{filter.path}%"))
|
||||||
|
else:
|
||||||
|
raise ApplicationError(f"Can't process filter path ({filter=})")
|
||||||
|
|
||||||
|
# Duration specification
|
||||||
|
seconds_duration = filter.duration_number
|
||||||
|
if filter.duration_unit == Config.FILTER_DURATION_MINUTES:
|
||||||
|
seconds_duration *= 60
|
||||||
|
elif filter.duration_unit != Config.FILTER_DURATION_SECONDS:
|
||||||
|
raise ApplicationError(f"Can't process filter duration ({filter=})")
|
||||||
|
|
||||||
|
if filter.duration_type == Config.FILTER_DURATION_LONGER:
|
||||||
|
query = query.where(cls.duration >= seconds_duration)
|
||||||
|
elif filter.duration_unit == Config.FILTER_DURATION_SHORTER:
|
||||||
|
query = query.where(cls.duration <= seconds_duration)
|
||||||
|
else:
|
||||||
|
raise ApplicationError(f"Can't process filter duration type ({filter=})")
|
||||||
|
|
||||||
|
# Process comparator
|
||||||
|
if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER:
|
||||||
|
# Select tracks that have never been played
|
||||||
|
query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where(
|
||||||
|
Playdates.id.is_(None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Last played specification
|
||||||
|
now = dt.datetime.now()
|
||||||
|
# Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME
|
||||||
|
before = now
|
||||||
|
# If not ANYTIME, set 'before' appropriates
|
||||||
|
if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME:
|
||||||
|
if filter.last_played_unit == Config.FILTER_PLAYED_DAYS:
|
||||||
|
before = now - dt.timedelta(days=filter.last_played_number)
|
||||||
|
elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS:
|
||||||
|
before = now - dt.timedelta(days=7 * filter.last_played_number)
|
||||||
|
elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS:
|
||||||
|
before = now - dt.timedelta(days=30 * filter.last_played_number)
|
||||||
|
elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS:
|
||||||
|
before = now - dt.timedelta(days=365 * filter.last_played_number)
|
||||||
|
|
||||||
|
subquery = (
|
||||||
|
select(
|
||||||
|
Playdates.track_id,
|
||||||
|
func.max(Playdates.lastplayed).label("max_last_played"),
|
||||||
|
)
|
||||||
|
.group_by(Playdates.track_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = query.join(subquery, Tracks.id == subquery.c.track_id).where(
|
||||||
|
subquery.c.max_last_played < before
|
||||||
|
)
|
||||||
|
|
||||||
|
records = session.scalars(query).unique().all()
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
|
||||||
|
"""
|
||||||
|
Return track with passed path, or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
session.execute(select(Tracks).where(Tracks.path == path))
|
||||||
|
.unique()
|
||||||
|
.scalar_one()
|
||||||
|
)
|
||||||
|
except NoResultFound:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]:
|
||||||
|
"""
|
||||||
|
Search case-insenstively for artists containing str
|
||||||
|
|
||||||
|
The query performs an outer join with 'joinedload' to populate the results
|
||||||
|
from the Playdates table at the same time. unique() needed; see
|
||||||
|
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.options(joinedload(Tracks.playdates))
|
||||||
|
.where(cls.artist.ilike(f"%{text}%"))
|
||||||
|
.order_by(cls.title)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]:
|
||||||
|
"""
|
||||||
|
Search case-insenstively for titles containing str
|
||||||
|
|
||||||
|
The query performs an outer join with 'joinedload' to populate the results
|
||||||
|
from the Playdates table at the same time. unique() needed; see
|
||||||
|
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
session.scalars(
|
||||||
|
select(cls)
|
||||||
|
.options(joinedload(Tracks.playdates))
|
||||||
|
.where(cls.title.like(f"{text}%"))
|
||||||
|
.order_by(cls.title)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
154
app/music.py
@ -1,154 +0,0 @@
|
|||||||
import os
|
|
||||||
import threading
|
|
||||||
import vlc
|
|
||||||
|
|
||||||
from config import Config
|
|
||||||
from datetime import datetime
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
from log import DEBUG, ERROR
|
|
||||||
|
|
||||||
|
|
||||||
class Music:
|
|
||||||
"""
|
|
||||||
Manage the playing of music tracks
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.current_track_start_time = None
|
|
||||||
self.fading = False
|
|
||||||
self.VLC = vlc.Instance()
|
|
||||||
self.player = None
|
|
||||||
self.track_path = None
|
|
||||||
self.max_volume = Config.VOLUME_VLC_DEFAULT
|
|
||||||
|
|
||||||
def fade(self):
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEBUG("music.fade()")
|
|
||||||
|
|
||||||
if not self.playing():
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.fading = True
|
|
||||||
|
|
||||||
thread = threading.Thread(target=self._fade)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def _fade(self):
|
|
||||||
"""
|
|
||||||
Implementation of fading the current track in a separate thread.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEBUG("music._fade()")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Take a copy of current player to allow another track to be
|
|
||||||
# started without interfering here
|
|
||||||
p = self.player
|
|
||||||
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)
|
|
||||||
|
|
||||||
p.stop()
|
|
||||||
p.release()
|
|
||||||
self.fading = False
|
|
||||||
|
|
||||||
def get_playtime(self):
|
|
||||||
"Return elapsed play time"
|
|
||||||
|
|
||||||
if not self.player:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.player.get_time()
|
|
||||||
|
|
||||||
def get_position(self):
|
|
||||||
"Return current position"
|
|
||||||
|
|
||||||
DEBUG("music.get_position")
|
|
||||||
|
|
||||||
return self.player.get_position()
|
|
||||||
|
|
||||||
def play(self, path):
|
|
||||||
"""
|
|
||||||
Start playing the track at path.
|
|
||||||
|
|
||||||
Log and return if path not found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEBUG(f"music.play({path})")
|
|
||||||
|
|
||||||
if not os.access(path, os.R_OK):
|
|
||||||
ERROR(f"play({path}): path not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.track_path = path
|
|
||||||
|
|
||||||
self.player = self.VLC.media_player_new(path)
|
|
||||||
self.player.audio_set_volume(self.max_volume)
|
|
||||||
self.player.play()
|
|
||||||
self.current_track_start_time = datetime.now()
|
|
||||||
|
|
||||||
def playing(self):
|
|
||||||
"""
|
|
||||||
Return True if currently playing a track, else False
|
|
||||||
|
|
||||||
vlc.is_playing() returns True if track was faded out.
|
|
||||||
get_position seems more reliable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.player:
|
|
||||||
if self.player.get_position() > 0 and self.player.is_playing():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# We take a copy of the player when fading, so we could be
|
|
||||||
# playing in a fade nowFalse
|
|
||||||
return self.fading
|
|
||||||
|
|
||||||
def set_position(self, ms):
|
|
||||||
"Set current play time in milliseconds from start"
|
|
||||||
|
|
||||||
return self.player.set_time(ms)
|
|
||||||
|
|
||||||
def set_volume(self, volume):
|
|
||||||
"Set maximum volume used for player"
|
|
||||||
|
|
||||||
if not self.player:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.max_volume = volume
|
|
||||||
self.player.audio_set_volume(volume)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"Immediately stop playing"
|
|
||||||
|
|
||||||
DEBUG("music.stop()")
|
|
||||||
|
|
||||||
if not self.player:
|
|
||||||
return None
|
|
||||||
|
|
||||||
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
@ -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()
|
||||||
3388
app/musicmuster.py
1836
app/playlistmodel.py
Normal file
1934
app/playlists.py
290
app/querylistmodel.py
Normal 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)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
197
app/songdb.py
@ -1,197 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from config import Config
|
|
||||||
from log import DEBUG, INFO
|
|
||||||
from model import Tracks, Session
|
|
||||||
from mutagen.flac import FLAC
|
|
||||||
from pydub import AudioSegment, effects
|
|
||||||
from tinytag import TinyTag
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"Main loop"
|
|
||||||
|
|
||||||
INFO("Starting")
|
|
||||||
|
|
||||||
# Parse command line
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument('-u', '--update',
|
|
||||||
action="store_true", dest="update",
|
|
||||||
default=True, help="Update database")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
# Run as required
|
|
||||||
if args.update:
|
|
||||||
INFO("Updating database")
|
|
||||||
with Session() as session:
|
|
||||||
update_db(session)
|
|
||||||
|
|
||||||
INFO("Finished")
|
|
||||||
|
|
||||||
|
|
||||||
def add_path_to_db(session, path):
|
|
||||||
"Add passed path to database along with metadata"
|
|
||||||
|
|
||||||
track = Tracks.get_or_create(session, path)
|
|
||||||
tag = TinyTag.get(path)
|
|
||||||
audio = get_audio_segment(path)
|
|
||||||
|
|
||||||
track.title = tag.title
|
|
||||||
track.artist = tag.artist
|
|
||||||
track.duration = int(round(tag.duration,
|
|
||||||
Config.MILLISECOND_SIGFIGS) * 1000)
|
|
||||||
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(path)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
if Config.NORMALISE_ON_IMPORT:
|
|
||||||
# Get current file gid, uid and permissions
|
|
||||||
stats = os.stat(path)
|
|
||||||
try:
|
|
||||||
# Copy original file
|
|
||||||
fd, temp_path = tempfile.mkstemp()
|
|
||||||
shutil.copyfile(path, temp_path)
|
|
||||||
except Exception as err:
|
|
||||||
DEBUG(f"songdb.add_path_to_db({path}): err1: {str(err)}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Overwrite original file with normalised output
|
|
||||||
normalised = effects.normalize(audio)
|
|
||||||
try:
|
|
||||||
normalised.export(path, format=os.path.splitext(path)[1][1:])
|
|
||||||
# Fix up permssions and ownership
|
|
||||||
os.chown(path, stats.st_uid, stats.st_gid)
|
|
||||||
os.chmod(path, stats.st_mode)
|
|
||||||
# Copy tags
|
|
||||||
src = FLAC(temp_path)
|
|
||||||
dst = FLAC(path)
|
|
||||||
for tag in src:
|
|
||||||
dst[tag] = src[tag]
|
|
||||||
dst.save()
|
|
||||||
except Exception as err:
|
|
||||||
DEBUG(f"songdb.add_path_to_db({path}): err2: {str(err)}")
|
|
||||||
# Restore original file
|
|
||||||
shutil.copyfile(path, temp_path)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_path):
|
|
||||||
os.remove(temp_path)
|
|
||||||
|
|
||||||
return track
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_segment(path):
|
|
||||||
try:
|
|
||||||
if path.endswith('.mp3'):
|
|
||||||
return AudioSegment.from_mp3(path)
|
|
||||||
elif path.endswith('.flac'):
|
|
||||||
return AudioSegment.from_file(path, "flac")
|
|
||||||
except AttributeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def leading_silence(audio_segment, silence_threshold=Config.DBFS_SILENCE,
|
|
||||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
|
||||||
"""
|
|
||||||
Returns the millisecond/index that the leading silence ends.
|
|
||||||
audio_segment - the segment to find silence in
|
|
||||||
silence_threshold - the upper bound for how quiet is silent in dFBS
|
|
||||||
chunk_size - chunk size for interating over the segment in ms
|
|
||||||
|
|
||||||
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
trim_ms = 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)):
|
|
||||||
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 fade_point(audio_segment, fade_threshold=Config.DBFS_FADE,
|
|
||||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
|
||||||
"""
|
|
||||||
Returns the millisecond/index of the point where the fade is down to
|
|
||||||
fade_threshold and doesn't get louder again.
|
|
||||||
audio_segment - the sdlg_search_database_uiegment to find silence in
|
|
||||||
fade_threshold - the upper bound for how quiet is silent in dFBS
|
|
||||||
chunk_size - chunk size for interating over the segment in ms
|
|
||||||
"""
|
|
||||||
|
|
||||||
assert chunk_size > 0 # to avoid infinite loop
|
|
||||||
|
|
||||||
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): # noqa W503
|
|
||||||
trim_ms -= chunk_size
|
|
||||||
|
|
||||||
# if there is no trailing silence, return lenght of track (it's less
|
|
||||||
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
|
|
||||||
return int(trim_ms)
|
|
||||||
|
|
||||||
|
|
||||||
# Current unused (1 June 2021)
|
|
||||||
# def rescan_database(session):
|
|
||||||
#
|
|
||||||
# tracks = Tracks.get_all_tracks(session)
|
|
||||||
# total_tracks = len(tracks)
|
|
||||||
# track_count = 0
|
|
||||||
# for track in tracks:
|
|
||||||
# track_count += 1
|
|
||||||
# print(f"Track {track_count} of {total_tracks}")
|
|
||||||
# audio = get_audio_segment(track.path)
|
|
||||||
# track.start_gap = leading_silence(audio)
|
|
||||||
# track.fade_at = fade_point(audio)
|
|
||||||
# track.silence_at = trailing_silence(audio)
|
|
||||||
# session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def trailing_silence(audio_segment, silence_threshold=-50.0,
|
|
||||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
|
||||||
return fade_point(audio_segment, silence_threshold, chunk_size)
|
|
||||||
|
|
||||||
|
|
||||||
def update_db(session):
|
|
||||||
"""
|
|
||||||
Repopulate database
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Search for tracks in only one of directory and database
|
|
||||||
|
|
||||||
db_paths = set(Tracks.get_all_paths(session))
|
|
||||||
|
|
||||||
os_paths_list = []
|
|
||||||
for root, dirs, files in os.walk(Config.ROOT):
|
|
||||||
for f in files:
|
|
||||||
path = os.path.join(root, f)
|
|
||||||
ext = os.path.splitext(f)[1]
|
|
||||||
if ext in [".flac", ".mp3"]:
|
|
||||||
os_paths_list.append(path)
|
|
||||||
os_paths = set(os_paths_list)
|
|
||||||
|
|
||||||
for path in list(db_paths - os_paths):
|
|
||||||
# TODO
|
|
||||||
INFO(f"To remove from database: {path}")
|
|
||||||
|
|
||||||
for path in list(os_paths - db_paths):
|
|
||||||
# TODO
|
|
||||||
INFO(f"Adding to dataabase: {path}")
|
|
||||||
add_path_to_db(session, path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__' and '__file__' in globals():
|
|
||||||
main()
|
|
||||||
94
app/ui/dlgQuery.ui
Normal 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 &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
@ -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
@ -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>
|
||||||
164
app/ui/dlg_Cart.ui
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DialogCartEdit</class>
|
||||||
|
<widget class="QDialog" name="DialogCartEdit">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>564</width>
|
||||||
|
<height>148</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Carts</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
|
<item row="0" column="0">
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>56</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>&Name:</string>
|
||||||
|
</property>
|
||||||
|
<property name="buddy">
|
||||||
|
<cstring>lineEditName</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1" colspan="2">
|
||||||
|
<widget class="QLineEdit" name="lineEditName">
|
||||||
|
<property name="inputMask">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="3">
|
||||||
|
<widget class="QCheckBox" name="chkEnabled">
|
||||||
|
<property name="text">
|
||||||
|
<string>&Enabled</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0">
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>56</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>File:</string>
|
||||||
|
</property>
|
||||||
|
<property name="buddy">
|
||||||
|
<cstring>lblPath</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QLabel" name="lblPath">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>301</width>
|
||||||
|
<height>41</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="textFormat">
|
||||||
|
<enum>Qt::PlainText</enum>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="3">
|
||||||
|
<widget class="QPushButton" name="btnFile">
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>31</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="1">
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>116</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="2" colspan="2">
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>DialogCartEdit</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>DialogCartEdit</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>
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?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>383</width>
|
|
||||||
<height>270</height>
|
|
||||||
</rect>
|
|
||||||
</property>
|
|
||||||
<property name="windowTitle">
|
|
||||||
<string>Dialog</string>
|
|
||||||
</property>
|
|
||||||
<layout class="QVBoxLayout" name="verticalLayout">
|
|
||||||
<item>
|
|
||||||
<layout class="QGridLayout" name="gridLayout">
|
|
||||||
<item row="0" column="0">
|
|
||||||
<widget class="QLabel" name="label">
|
|
||||||
<property name="text">
|
|
||||||
<string>Title:</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="0" column="1">
|
|
||||||
<widget class="QLineEdit" name="searchString"/>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QListWidget" name="matchList"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QLabel" name="dbPath">
|
|
||||||
<property name="text">
|
|
||||||
<string/>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
|
||||||
<item>
|
|
||||||
<spacer name="horizontalSpacer">
|
|
||||||
<property name="orientation">
|
|
||||||
<enum>Qt::Horizontal</enum>
|
|
||||||
</property>
|
|
||||||
<property name="sizeHint" stdset="0">
|
|
||||||
<size>
|
|
||||||
<width>88</width>
|
|
||||||
<height>20</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
</spacer>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QPushButton" name="btnAdd">
|
|
||||||
<property name="text">
|
|
||||||
<string>&Add</string>
|
|
||||||
</property>
|
|
||||||
<property name="default">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QPushButton" name="btnAddClose">
|
|
||||||
<property name="text">
|
|
||||||
<string>A&dd and close</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QPushButton" name="btnClose">
|
|
||||||
<property name="text">
|
|
||||||
<string>&Close</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</widget>
|
|
||||||
<resources/>
|
|
||||||
<connections/>
|
|
||||||
</ui>
|
|
||||||
34
app/ui/dlg_SelectPlaylist_ui.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Form implementation generated from reading ui file 'app/ui/dlg_SelectPlaylist.ui'
|
||||||
|
#
|
||||||
|
# Created by: PyQt6 UI code generator 6.5.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_dlgSelectPlaylist(object):
|
||||||
|
def setupUi(self, dlgSelectPlaylist):
|
||||||
|
dlgSelectPlaylist.setObjectName("dlgSelectPlaylist")
|
||||||
|
dlgSelectPlaylist.resize(276, 150)
|
||||||
|
self.verticalLayout = QtWidgets.QVBoxLayout(dlgSelectPlaylist)
|
||||||
|
self.verticalLayout.setObjectName("verticalLayout")
|
||||||
|
self.lstPlaylists = QtWidgets.QListWidget(parent=dlgSelectPlaylist)
|
||||||
|
self.lstPlaylists.setObjectName("lstPlaylists")
|
||||||
|
self.verticalLayout.addWidget(self.lstPlaylists)
|
||||||
|
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) # 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"))
|
||||||
131
app/ui/dlg_TrackSelect.ui
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<?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>584</width>
|
||||||
|
<height>377</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Dialog</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
|
<item row="0" column="0">
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="text">
|
||||||
|
<string>Title:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="QLineEdit" name="searchString"/>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" colspan="2">
|
||||||
|
<widget class="QListWidget" name="matchList"/>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="0" colspan="2">
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="lblNote">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>46</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>&Note:</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
|
||||||
|
</property>
|
||||||
|
<property name="buddy">
|
||||||
|
<cstring>txtNote</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="txtNote"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<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">
|
||||||
|
<string>&Title</string>
|
||||||
|
</property>
|
||||||
|
<property name="checked">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QRadioButton" name="radioArtist">
|
||||||
|
<property name="text">
|
||||||
|
<string>&Artist</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer_2">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="btnAdd">
|
||||||
|
<property name="text">
|
||||||
|
<string>&Add</string>
|
||||||
|
</property>
|
||||||
|
<property name="default">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="btnAddClose">
|
||||||
|
<property name="text">
|
||||||
|
<string>A&dd and close</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="btnClose">
|
||||||
|
<property name="text">
|
||||||
|
<string>&Close</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
83
app/ui/dlg_TrackSelect_ui.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Form implementation generated from reading ui file 'dlg_TrackSelect.ui'
|
||||||
|
#
|
||||||
|
# Created by: PyQt6 UI code generator 6.5.3
|
||||||
|
#
|
||||||
|
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
||||||
|
# run again. Do not edit this file unless you know what you are doing.
|
||||||
|
|
||||||
|
|
||||||
|
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
|
||||||
|
class Ui_Dialog(object):
|
||||||
|
def setupUi(self, Dialog):
|
||||||
|
Dialog.setObjectName("Dialog")
|
||||||
|
Dialog.resize(584, 377)
|
||||||
|
self.gridLayout = QtWidgets.QGridLayout(Dialog)
|
||||||
|
self.gridLayout.setObjectName("gridLayout")
|
||||||
|
self.label = QtWidgets.QLabel(parent=Dialog)
|
||||||
|
self.label.setObjectName("label")
|
||||||
|
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
|
||||||
|
self.searchString = QtWidgets.QLineEdit(parent=Dialog)
|
||||||
|
self.searchString.setObjectName("searchString")
|
||||||
|
self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1)
|
||||||
|
self.matchList = QtWidgets.QListWidget(parent=Dialog)
|
||||||
|
self.matchList.setObjectName("matchList")
|
||||||
|
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.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
|
||||||
|
self.lblNote.setObjectName("lblNote")
|
||||||
|
self.horizontalLayout.addWidget(self.lblNote)
|
||||||
|
self.txtNote = QtWidgets.QLineEdit(parent=Dialog)
|
||||||
|
self.txtNote.setObjectName("txtNote")
|
||||||
|
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.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)
|
||||||
|
QtCore.QMetaObject.connectSlotsByName(Dialog)
|
||||||
|
|
||||||
|
def retranslateUi(self, Dialog):
|
||||||
|
_translate = QtCore.QCoreApplication.translate
|
||||||
|
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
|
||||||
|
self.label.setText(_translate("Dialog", "Title:"))
|
||||||
|
self.lblNote.setText(_translate("Dialog", "&Note:"))
|
||||||
|
self.radioTitle.setText(_translate("Dialog", "&Title"))
|
||||||
|
self.radioArtist.setText(_translate("Dialog", "&Artist"))
|
||||||
|
self.btnAdd.setText(_translate("Dialog", "&Add"))
|
||||||
|
self.btnAddClose.setText(_translate("Dialog", "A&dd and close"))
|
||||||
|
self.btnClose.setText(_translate("Dialog", "&Close"))
|
||||||
71
app/ui/dlg_cart_ui.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Form implementation generated from reading ui file 'dlg_Cart.ui'
|
||||||
|
#
|
||||||
|
# Created by: PyQt6 UI code generator 6.5.3
|
||||||
|
#
|
||||||
|
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
||||||
|
# run again. Do not edit this file unless you know what you are doing.
|
||||||
|
|
||||||
|
|
||||||
|
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
|
||||||
|
class Ui_DialogCartEdit(object):
|
||||||
|
def setupUi(self, DialogCartEdit):
|
||||||
|
DialogCartEdit.setObjectName("DialogCartEdit")
|
||||||
|
DialogCartEdit.resize(564, 148)
|
||||||
|
self.gridLayout = QtWidgets.QGridLayout(DialogCartEdit)
|
||||||
|
self.gridLayout.setObjectName("gridLayout")
|
||||||
|
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(parent=DialogCartEdit)
|
||||||
|
self.lineEditName.setInputMask("")
|
||||||
|
self.lineEditName.setObjectName("lineEditName")
|
||||||
|
self.gridLayout.addWidget(self.lineEditName, 0, 1, 1, 2)
|
||||||
|
self.chkEnabled = QtWidgets.QCheckBox(parent=DialogCartEdit)
|
||||||
|
self.chkEnabled.setObjectName("chkEnabled")
|
||||||
|
self.gridLayout.addWidget(self.chkEnabled, 0, 3, 1, 1)
|
||||||
|
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(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.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(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.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
|
||||||
|
self.gridLayout.addItem(spacerItem, 2, 1, 1, 1)
|
||||||
|
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)
|
||||||
|
self.label_2.setBuddy(self.lblPath)
|
||||||
|
|
||||||
|
self.retranslateUi(DialogCartEdit)
|
||||||
|
self.buttonBox.accepted.connect(DialogCartEdit.accept) # type: ignore
|
||||||
|
self.buttonBox.rejected.connect(DialogCartEdit.reject) # type: ignore
|
||||||
|
QtCore.QMetaObject.connectSlotsByName(DialogCartEdit)
|
||||||
|
|
||||||
|
def retranslateUi(self, DialogCartEdit):
|
||||||
|
_translate = QtCore.QCoreApplication.translate
|
||||||
|
DialogCartEdit.setWindowTitle(_translate("DialogCartEdit", "Carts"))
|
||||||
|
self.label.setText(_translate("DialogCartEdit", "&Name:"))
|
||||||
|
self.chkEnabled.setText(_translate("DialogCartEdit", "&Enabled"))
|
||||||
|
self.label_2.setText(_translate("DialogCartEdit", "File:"))
|
||||||
|
self.btnFile.setText(_translate("DialogCartEdit", "..."))
|
||||||
53
app/ui/dlg_replace_files_ui.py
Normal 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"))
|
||||||
107
app/ui/downloadcsv.ui
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DateSelect</class>
|
||||||
|
<widget class="QDialog" name="DateSelect">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>280</width>
|
||||||
|
<height>166</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Dialog</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>70</x>
|
||||||
|
<y>110</y>
|
||||||
|
<width>191</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="QDateTimeEdit" name="dateTimeEdit">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>70</x>
|
||||||
|
<y>60</y>
|
||||||
|
<width>194</width>
|
||||||
|
<height>28</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="calendarPopup">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>10</x>
|
||||||
|
<y>20</y>
|
||||||
|
<width>261</width>
|
||||||
|
<height>19</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Download CSV of tracks played</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>15</x>
|
||||||
|
<y>66</y>
|
||||||
|
<width>51</width>
|
||||||
|
<height>19</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Since:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>DateSelect</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>DateSelect</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>
|
||||||
41
app/ui/downloadcsv_ui.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Form implementation generated from reading ui file 'app/ui/downloadcsv.ui'
|
||||||
|
#
|
||||||
|
# Created by: PyQt6 UI code generator 6.5.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_DateSelect(object):
|
||||||
|
def setupUi(self, DateSelect):
|
||||||
|
DateSelect.setObjectName("DateSelect")
|
||||||
|
DateSelect.resize(280, 166)
|
||||||
|
self.buttonBox = QtWidgets.QDialogButtonBox(parent=DateSelect)
|
||||||
|
self.buttonBox.setGeometry(QtCore.QRect(70, 110, 191, 32))
|
||||||
|
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(parent=DateSelect)
|
||||||
|
self.dateTimeEdit.setGeometry(QtCore.QRect(70, 60, 194, 28))
|
||||||
|
self.dateTimeEdit.setCalendarPopup(True)
|
||||||
|
self.dateTimeEdit.setObjectName("dateTimeEdit")
|
||||||
|
self.label = QtWidgets.QLabel(parent=DateSelect)
|
||||||
|
self.label.setGeometry(QtCore.QRect(10, 20, 261, 19))
|
||||||
|
self.label.setObjectName("label")
|
||||||
|
self.label_2 = QtWidgets.QLabel(parent=DateSelect)
|
||||||
|
self.label_2.setGeometry(QtCore.QRect(15, 66, 51, 19))
|
||||||
|
self.label_2.setObjectName("label_2")
|
||||||
|
|
||||||
|
self.retranslateUi(DateSelect)
|
||||||
|
self.buttonBox.accepted.connect(DateSelect.accept) # type: ignore
|
||||||
|
self.buttonBox.rejected.connect(DateSelect.reject) # type: ignore
|
||||||
|
QtCore.QMetaObject.connectSlotsByName(DateSelect)
|
||||||
|
|
||||||
|
def retranslateUi(self, DateSelect):
|
||||||
|
_translate = QtCore.QCoreApplication.translate
|
||||||
|
DateSelect.setWindowTitle(_translate("DateSelect", "Dialog"))
|
||||||
|
self.label.setText(_translate("DateSelect", "Download CSV of tracks played"))
|
||||||
|
self.label_2.setText(_translate("DateSelect", "Since:"))
|
||||||
BIN
app/ui/green-circle.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
app/ui/headphone-symbol.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
@ -1,5 +1,14 @@
|
|||||||
<RCC>
|
<RCC>
|
||||||
<qresource prefix="icons">
|
<qresource prefix="icons">
|
||||||
|
<file>yellow-circle.png</file>
|
||||||
|
<file>redstar.png</file>
|
||||||
|
<file>green-circle.png</file>
|
||||||
|
<file>star.png</file>
|
||||||
|
<file>star_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="stopsign">stopsign.png</file>
|
||||||
<file alias="wikipedia">wikipedia-logo.png</file>
|
<file alias="wikipedia">wikipedia-logo.png</file>
|
||||||
<file alias="songsearch">songsearch_icon.png</file>
|
<file alias="songsearch">songsearch_icon.png</file>
|
||||||
|
|||||||
40375
app/ui/icons_rc.py
589
app/ui/main_window_footer.ui
Normal 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><<</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>>></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><</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>></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>
|
||||||
274
app/ui/main_window_footer_ui.py
Normal 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
|
||||||
314
app/ui/main_window_header.ui
Normal 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>
|
||||||
178
app/ui/main_window_header_ui.py
Normal 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"))
|
||||||
42
app/ui/main_window_playlist.ui
Normal 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>
|
||||||
34
app/ui/main_window_playlist_ui.py
Normal 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"))
|
||||||
BIN
app/ui/musicmuster.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
app/ui/musicmuster.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/ui/record-button.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
app/ui/record-red-button.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
app/ui/redstar.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/ui/star.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/ui/star_empty.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/ui/yellow-circle.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
97
app/utilities.py
Executable file
@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Standard library imports
|
||||||
|
import os
|
||||||
|
|
||||||
|
# PyQt imports
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from config import Config
|
||||||
|
from helpers import (
|
||||||
|
get_tags,
|
||||||
|
)
|
||||||
|
from log import log
|
||||||
|
from models import Tracks
|
||||||
|
|
||||||
|
|
||||||
|
def check_db(session: Session) -> None:
|
||||||
|
"""
|
||||||
|
Database consistency check.
|
||||||
|
|
||||||
|
A report is generated if issues are found, but there are no automatic
|
||||||
|
corrections made.
|
||||||
|
|
||||||
|
Search for tracks that are in the music directory but not the datebase
|
||||||
|
Check all paths in database exist
|
||||||
|
"""
|
||||||
|
|
||||||
|
db_paths = set([a.path for a in Tracks.get_all(session)])
|
||||||
|
|
||||||
|
os_paths_list = []
|
||||||
|
for root, _dirs, files in os.walk(Config.ROOT):
|
||||||
|
for f in files:
|
||||||
|
path = os.path.join(root, f)
|
||||||
|
ext = os.path.splitext(f)[1]
|
||||||
|
if ext in [".flac", ".mp3"]:
|
||||||
|
os_paths_list.append(path)
|
||||||
|
os_paths = set(os_paths_list)
|
||||||
|
|
||||||
|
# Find any files in music directory that are not in database
|
||||||
|
files_not_in_db = list(os_paths - db_paths)
|
||||||
|
|
||||||
|
# Find paths in database missing in music directory
|
||||||
|
paths_not_found = []
|
||||||
|
missing_file_count = 0
|
||||||
|
more_files_to_report = False
|
||||||
|
for path in list(db_paths - os_paths):
|
||||||
|
if missing_file_count >= Config.MAX_MISSING_FILES_TO_REPORT:
|
||||||
|
more_files_to_report = True
|
||||||
|
break
|
||||||
|
|
||||||
|
missing_file_count += 1
|
||||||
|
|
||||||
|
track = Tracks.get_by_path(session, path)
|
||||||
|
if not track:
|
||||||
|
# This shouldn't happen as we're looking for paths in
|
||||||
|
# database that aren't in filesystem, but just in case...
|
||||||
|
log.error(f"update_db: {path} not found in db")
|
||||||
|
continue
|
||||||
|
|
||||||
|
paths_not_found.append(track)
|
||||||
|
|
||||||
|
# Output messages (so if running via cron, these will get sent to
|
||||||
|
# user)
|
||||||
|
if files_not_in_db:
|
||||||
|
print("Files in music directory but not in database")
|
||||||
|
print("--------------------------------------------")
|
||||||
|
print("\n".join(files_not_in_db))
|
||||||
|
print("\n")
|
||||||
|
if paths_not_found:
|
||||||
|
print("Invalid paths in database")
|
||||||
|
print("-------------------------")
|
||||||
|
for t in paths_not_found:
|
||||||
|
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: Session) -> None:
|
||||||
|
"""
|
||||||
|
Update bitrates on all tracks in database
|
||||||
|
"""
|
||||||
|
|
||||||
|
for track in Tracks.get_all(session):
|
||||||
|
try:
|
||||||
|
t = get_tags(track.path)
|
||||||
|
track.bitrate = t.bitrate
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
29
app/vlcmanager.py
Normal 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
@ -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())
|
||||||
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
import datetime as dt
|
||||||
from threading import Timer
|
from threading import Timer
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from timeloop import Timeloop
|
from timeloop import Timeloop # type: ignore
|
||||||
import vlc
|
import vlc # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class RepeatedTimer(object):
|
class RepeatedTimer(object):
|
||||||
@ -49,9 +49,9 @@ def leading_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
|
|||||||
|
|
||||||
trim_ms = 0 # ms
|
trim_ms = 0 # ms
|
||||||
assert chunk_size > 0 # to avoid infinite loop
|
assert chunk_size > 0 # to avoid infinite loop
|
||||||
while (
|
while audio_segment[
|
||||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
|
trim_ms : trim_ms + chunk_size
|
||||||
and trim_ms < len(audio_segment)):
|
].dBFS < silence_threshold and trim_ms < len(audio_segment):
|
||||||
trim_ms += chunk_size
|
trim_ms += chunk_size
|
||||||
|
|
||||||
# if there is no end it should return the length of the segment
|
# 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
|
segment_length = audio_segment.duration_seconds * 1000 # ms
|
||||||
trim_ms = segment_length - chunk_size
|
trim_ms = segment_length - chunk_size
|
||||||
while (
|
while (
|
||||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
|
||||||
and trim_ms > 0):
|
and trim_ms > 0
|
||||||
|
):
|
||||||
trim_ms -= chunk_size
|
trim_ms -= chunk_size
|
||||||
|
|
||||||
# if there is no trailing silence, return lenght of track (it's less
|
# if there is no trailing silence, return lenght of track (it's less
|
||||||
@ -94,8 +95,9 @@ def trailing_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
|
|||||||
segment_length = audio_segment.duration_seconds * 1000 # ms
|
segment_length = audio_segment.duration_seconds * 1000 # ms
|
||||||
trim_ms = segment_length - chunk_size
|
trim_ms = segment_length - chunk_size
|
||||||
while (
|
while (
|
||||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
|
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < silence_threshold
|
||||||
and trim_ms > 0):
|
and trim_ms > 0
|
||||||
|
):
|
||||||
trim_ms -= chunk_size
|
trim_ms -= chunk_size
|
||||||
|
|
||||||
# if there is no trailing silence, return lenght of track (it's less
|
# if there is no trailing silence, return lenght of track (it's less
|
||||||
@ -124,15 +126,17 @@ def update_progress(player, talk_at, silent_at):
|
|||||||
remaining_time = total_time - elapsed_time
|
remaining_time = total_time - elapsed_time
|
||||||
talk_time = remaining_time - (total_time - talk_at)
|
talk_time = remaining_time - (total_time - talk_at)
|
||||||
silent_time = remaining_time - (total_time - silent_at)
|
silent_time = remaining_time - (total_time - silent_at)
|
||||||
end_time = (datetime.now() + timedelta(
|
end_time = (dt.datetime.now() + timedelta(milliseconds=remaining_time)).strftime(
|
||||||
milliseconds=remaining_time)).strftime("%H:%M:%S")
|
"%H:%M:%S"
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f"\t{ms_to_mmss(elapsed_time)}/"
|
f"\t{ms_to_mmss(elapsed_time)}/"
|
||||||
f"{ms_to_mmss(total_time)}\t\t"
|
f"{ms_to_mmss(total_time)}\t\t"
|
||||||
f"Talk in: {ms_to_mmss(talk_time)} "
|
f"Talk in: {ms_to_mmss(talk_time)} "
|
||||||
f"Silent in: {ms_to_mmss(silent_time)} "
|
f"Silent in: {ms_to_mmss(silent_time)} "
|
||||||
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]"
|
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]",
|
||||||
, end="\r")
|
end="\r",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Print name of current song, print name of next song. Play current when
|
# Print name of current song, print name of next song. Play current when
|
||||||
@ -163,21 +167,21 @@ def test():
|
|||||||
|
|
||||||
test()
|
test()
|
||||||
# next_song = get_next_song
|
# next_song = get_next_song
|
||||||
#
|
#
|
||||||
# def play_track():
|
# def play_track():
|
||||||
# r = run_aud_cmd("--current-song-length
|
# r = run_aud_cmd("--current-song-length
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# def play():
|
# def play():
|
||||||
# play_track()
|
# play_track()
|
||||||
# songtimer_start()
|
# songtimer_start()
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# print("Start playing in 3 seconds")
|
# print("Start playing in 3 seconds")
|
||||||
#
|
#
|
||||||
# sleep(3)
|
# sleep(3)
|
||||||
#
|
#
|
||||||
# play()
|
# play()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
84
archive/db_experiments.py
Executable 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
@ -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())
|
||||||
46
archive/play.py
Executable file
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
|
||||||
|
def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
|
||||||
|
"""
|
||||||
|
Returns the millisecond/index of the point where the fade is down to
|
||||||
|
fade_threshold and doesn't get louder again.
|
||||||
|
audio_segment - the sdlg_search_database_uiegment to find silence in
|
||||||
|
fade_threshold - the upper bound for how quiet is silent in dFBS
|
||||||
|
chunk_size - chunk size for interating over the segment in ms
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert chunk_size > 0 # to avoid infinite loop
|
||||||
|
|
||||||
|
segment_length = audio_segment.duration_seconds * 1000 # ms
|
||||||
|
print(f"segment_length={int(segment_length/1000)}")
|
||||||
|
trim_ms = segment_length - chunk_size
|
||||||
|
|
||||||
|
max_vol = audio_segment.dBFS
|
||||||
|
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
|
||||||
|
trim_ms -= chunk_size
|
||||||
|
|
||||||
|
# if there is no trailing silence, return lenght of track (it's less
|
||||||
|
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
|
||||||
|
print(f"Fade last {int(segment_length - trim_ms)/1000} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
# print("Shout:")
|
||||||
|
# segment = AudioSegment.from_mp3("../archive/shout.mp3")
|
||||||
|
# fade_point(segment)
|
||||||
|
# print("Champagne:")
|
||||||
|
# segment = AudioSegment.from_mp3("../archive/champ.mp3")
|
||||||
|
# fade_point(segment)
|
||||||
|
# print("Be good:")
|
||||||
|
# segment = AudioSegment.from_mp3("../archive/wibg.mp3")
|
||||||
|
# fade_point(segment)
|
||||||
|
print("Be good:")
|
||||||
|
segment = AudioSegment.from_file("/tmp/bia.flac", "flac")
|
||||||
|
fade_point(segment)
|
||||||
125
archive/proxymodel.py
Executable 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())
|
||||||
|
|
||||||
98
archive/qtableview-reorder-working.py
Executable 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_())
|
||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
# tl = Timeloop()
|
# tl = Timeloop()
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
@ -48,34 +46,34 @@
|
|||||||
# rt.stop() # better in a try/finally block to make sure the program ends!
|
# rt.stop() # better in a try/finally block to make sure the program ends!
|
||||||
# print("End")
|
# print("End")
|
||||||
|
|
||||||
#def kae2(self, index):
|
# def kae2(self, index):
|
||||||
# print(f"table header click, index={index}")
|
# print(f"table header click, index={index}")
|
||||||
|
|
||||||
#def kae(self, a, b, c):
|
# def kae(self, a, b, c):
|
||||||
# self.data.append(f"a={a}, b={b}, c={c}")
|
# self.data.append(f"a={a}, b={b}, c={c}")
|
||||||
|
|
||||||
#def mousePressEvent(self, QMouseEvent):
|
# def mousePressEvent(self, QMouseEvent):
|
||||||
# print("mouse press")
|
# print("mouse press")
|
||||||
|
|
||||||
#def mouseReleaseEvent(self, QMouseEvent):
|
# def mouseReleaseEvent(self, QMouseEvent):
|
||||||
# print("mouse release")
|
# print("mouse release")
|
||||||
# # QMessageBox.about(
|
# # QMessageBox.about(
|
||||||
# # self,
|
# # self,
|
||||||
# # "About Sample Editor",
|
# # "About Sample Editor",
|
||||||
# # "\n".join(self.data)
|
# # "\n".join(self.data)
|
||||||
# # )
|
# # )
|
||||||
#def eventFilter(self, obj, event):
|
# def eventFilter(self, obj, event):
|
||||||
# # you could be doing different groups of actions
|
# # you could be doing different groups of actions
|
||||||
# # for different types of widgets and either filtering
|
# # for different types of widgets and either filtering
|
||||||
# # the event or not.
|
# # the event or not.
|
||||||
# # Here we just check if its one of the layout widgets
|
# # Here we just check if its one of the layout widgets
|
||||||
# # if self.layout.indexOf(obj) != -1:
|
# # if self.layout.indexOf(obj) != -1:
|
||||||
# # print(f"event received: {event.type()}")
|
# # print(f"event received: {event.type()}")
|
||||||
# if event.type() == QEvent.MouseButtonPress:
|
# if event.type() == QEvent.MouseButtonPress:
|
||||||
# print("Widget click")
|
# print("Widget click")
|
||||||
# # if I returned True right here, the event
|
# # if I returned True right here, the event
|
||||||
# # would be filtered and not reach the obj,
|
# # would be filtered and not reach the obj,
|
||||||
# # meaning that I decided to handle it myself
|
# # meaning that I decided to handle it myself
|
||||||
|
|
||||||
# # regardless, just do the default
|
# # regardless, just do the default
|
||||||
# return super().eventFilter(obj, event)
|
# return super().eventFilter(obj, event)
|
||||||
|
|||||||
BIN
archive/todo/.DS_Store
vendored
Normal file
1
archive/todo/data.db
Normal file
@ -0,0 +1 @@
|
|||||||
|
[[false, "My first todo"], [true, "My second todo"], [true, "Another todo"], [false, "as"]]
|
||||||
71
archive/todo/mainwindow.ui
Normal 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
|
After Width: | Height: | Size: 634 B |
104
archive/todo/todo.py
Normal 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
@ -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"""
|
||||||
20
docs/Makefile
Normal 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
BIN
docs/build/doctrees/environment.pickle
vendored
Normal file
BIN
docs/build/doctrees/index.doctree
vendored
Normal file
BIN
docs/build/doctrees/installation.doctree
vendored
Normal file
BIN
docs/build/doctrees/introduction.doctree
vendored
Normal file
BIN
docs/build/doctrees/reference.doctree
vendored
Normal file
BIN
docs/build/doctrees/tutorial.doctree
vendored
Normal file
4
docs/build/html/.buildinfo
vendored
Normal 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
|
||||||
2
docs/build/html/_sources/development.rst.txt
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Development
|
||||||
|
===========
|
||||||
25
docs/build/html/_sources/index.rst.txt
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.. MusicMuster documentation master file, created by
|
||||||
|
sphinx-quickstart on Sun Jul 2 17:58:44 2023.
|
||||||
|
|
||||||
|
Welcome to MusicMuster's documentation!
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
**MusicMuster** is a music player targeted at the production of live
|
||||||
|
internet radio shows.
|
||||||
|
|
||||||
|
Contents
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
introduction
|
||||||
|
installation
|
||||||
|
tutorial
|
||||||
|
reference
|
||||||
|
development
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
2
docs/build/html/_sources/installation.rst.txt
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Installation
|
||||||
|
============
|
||||||
87
docs/build/html/_sources/introduction.rst.txt
vendored
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
Introduction
|
||||||
|
============
|
||||||
|
|
||||||
|
Why MusicMuster?
|
||||||
|
----------------
|
||||||
|
|
||||||
|
In January 2022 I started my show on `Mixcloud
|
||||||
|
<https://www.mixcloud.com/KeithsMusicBox/>`. Until then, my show had
|
||||||
|
been on an internet radio station which required me to use a Windows
|
||||||
|
playout system. As I only use Linux, I had to set up a Windows PC
|
||||||
|
specifically for that purpose. The system I had to use had what I felt
|
||||||
|
were shortcomings in various areas.
|
||||||
|
|
||||||
|
Once I moved to Mixcloud I searched for a Linux equivalent that didn't
|
||||||
|
have the same shortcomings but was unable to find one that met my
|
||||||
|
criteria. I decided to see how practical it would be to write my own,
|
||||||
|
and MusicMuster was born.
|
||||||
|
|
||||||
|
What is MusicMuster?
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
It is a Linux-based music player. Whilst it could be used as a general
|
||||||
|
home music player, there are much better applications for that role.
|
||||||
|
**MusicMuster** has been specifically designed to support the
|
||||||
|
production of live internet radio shows.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
* Database backed
|
||||||
|
* Can be almost entirely keyboard driven
|
||||||
|
* Open multiple playlists in tabs
|
||||||
|
* Play tracks from any playlist
|
||||||
|
* Add notes/comments to tracks on playlist
|
||||||
|
* Automatatic colour-coding of notes/comments according to content
|
||||||
|
* Preview tracks before playing to audience
|
||||||
|
* Time of day clock
|
||||||
|
* Elapsed track time counter
|
||||||
|
* Time to run until track starts to fade
|
||||||
|
* Time to run until track is silent
|
||||||
|
* Graphic of volume from 5 seconds (configurable) before fade until
|
||||||
|
track is silent
|
||||||
|
* Optionally hide played tracks in playlist
|
||||||
|
* Button to drop playout volume by 3dB for talkover
|
||||||
|
* Playlist displays:
|
||||||
|
* Title
|
||||||
|
* Artist
|
||||||
|
* Length of track (mm:ss)
|
||||||
|
* Estimated start time of track
|
||||||
|
* Estimated end time of track
|
||||||
|
* When track was last played
|
||||||
|
* Bits per second (bitrate) of track
|
||||||
|
* Length of leading silence in recording before track starts
|
||||||
|
* Total track length of arbitrary sections of tracks
|
||||||
|
* Commands that are sent to OBS Studio (eg, for automated scene
|
||||||
|
changes)
|
||||||
|
* Playlist templates
|
||||||
|
* Move selected or unplayed tracks between playlists
|
||||||
|
* Download CSV of tracks played between arbitrary dates/times
|
||||||
|
* Search for tracks by title or artist
|
||||||
|
* Automatic search of current and next track in Wikipedia
|
||||||
|
* Optional search of any track in Wikipedia
|
||||||
|
* Optional search of any track in Songfacts
|
||||||
|
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. note:: MusicMuster has only been tested on Debian 12, "Bookworm";
|
||||||
|
however, it should run on most contemporary Linux systems.
|
||||||
|
|
||||||
|
The :doc:`installation` page explains how to build MusicMuster in its
|
||||||
|
own environment which will automatcally install all requirements
|
||||||
|
except the database. The current version of MusicMuster uses MariaDB
|
||||||
|
version 10.11; however, any recent version of MariaDB should suffice.
|
||||||
|
|
||||||
|
MusicMuster is a Python 3 application and requires Python 3.8 or
|
||||||
|
later.
|
||||||
|
|
||||||
|
|
||||||
|
Feedback, bugs, etc
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Please send to keith@midnighthax.com
|
||||||
|
|
||||||
|
Keith Edmunds,
|
||||||
|
July 2023
|
||||||
2
docs/build/html/_sources/reference.rst.txt
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Reference
|
||||||
|
=========
|
||||||
2
docs/build/html/_sources/tutorial.rst.txt
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Tutorial
|
||||||
|
========
|
||||||
703
docs/build/html/_static/alabaster.css
vendored
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
@import url("basic.css");
|
||||||
|
|
||||||
|
/* -- page layout ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 17px;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
div.document {
|
||||||
|
width: 940px;
|
||||||
|
margin: 30px auto 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.documentwrapper {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.bodywrapper {
|
||||||
|
margin: 0 0 0 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar {
|
||||||
|
width: 220px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 1px solid #B1B4B6;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #3E4349;
|
||||||
|
padding: 0 30px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body > .section {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.footer {
|
||||||
|
width: 940px;
|
||||||
|
margin: 20px auto 30px auto;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.footer a {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.caption {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
div.relations {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
div.sphinxsidebar a {
|
||||||
|
color: #444;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar a:hover {
|
||||||
|
border-bottom: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper {
|
||||||
|
padding: 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper p.logo {
|
||||||
|
padding: 0;
|
||||||
|
margin: -10px 0 0 0px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper h1.logo {
|
||||||
|
margin-top: -10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper h1.logo-name {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper p.blurb {
|
||||||
|
margin-top: 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar h3,
|
||||||
|
div.sphinxsidebar h4 {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
color: #444;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar h4 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar h3 a {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar p.logo a,
|
||||||
|
div.sphinxsidebar h3 a,
|
||||||
|
div.sphinxsidebar p.logo a:hover,
|
||||||
|
div.sphinxsidebar h3 a:hover {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar p {
|
||||||
|
color: #555;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul li.toctree-l1 > a {
|
||||||
|
font-size: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul li.toctree-l2 > a {
|
||||||
|
font-size: 110%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar input {
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar hr {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
color: #AAA;
|
||||||
|
background: #AAA;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 0;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar .badge {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar .badge:hover {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* To address an issue with donation coming after search */
|
||||||
|
div.sphinxsidebar h3.donation {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- body styles ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #004B6B;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #6D4100;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body h1,
|
||||||
|
div.body h2,
|
||||||
|
div.body h3,
|
||||||
|
div.body h4,
|
||||||
|
div.body h5,
|
||||||
|
div.body h6 {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 30px 0px 10px 0px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
|
||||||
|
div.body h2 { font-size: 180%; }
|
||||||
|
div.body h3 { font-size: 150%; }
|
||||||
|
div.body h4 { font-size: 130%; }
|
||||||
|
div.body h5 { font-size: 100%; }
|
||||||
|
div.body h6 { font-size: 100%; }
|
||||||
|
|
||||||
|
a.headerlink {
|
||||||
|
color: #DDD;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.headerlink:hover {
|
||||||
|
color: #444;
|
||||||
|
background: #EAEAEA;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body p, div.body dd, div.body li {
|
||||||
|
line-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.admonition {
|
||||||
|
margin: 20px 0px;
|
||||||
|
padding: 10px 30px;
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.admonition tt.xref, div.admonition code.xref, div.admonition a tt {
|
||||||
|
background-color: #FBFBFB;
|
||||||
|
border-bottom: 1px solid #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.admonition p.admonition-title {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.admonition p.last {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.highlight {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt:target, .highlight {
|
||||||
|
background: #FAF3E8;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.warning {
|
||||||
|
background-color: #FCC;
|
||||||
|
border: 1px solid #FAA;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.danger {
|
||||||
|
background-color: #FCC;
|
||||||
|
border: 1px solid #FAA;
|
||||||
|
-moz-box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
-webkit-box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.error {
|
||||||
|
background-color: #FCC;
|
||||||
|
border: 1px solid #FAA;
|
||||||
|
-moz-box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
-webkit-box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
box-shadow: 2px 2px 4px #D52C2C;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.caution {
|
||||||
|
background-color: #FCC;
|
||||||
|
border: 1px solid #FAA;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.attention {
|
||||||
|
background-color: #FCC;
|
||||||
|
border: 1px solid #FAA;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.important {
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.note {
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.tip {
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hint {
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.seealso {
|
||||||
|
background-color: #EEE;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.topic {
|
||||||
|
background-color: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.admonition-title {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.admonition-title:after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, tt, code {
|
||||||
|
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hll {
|
||||||
|
background-color: #FFC;
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.screenshot {
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.descname, tt.descclassname, code.descname, code.descclassname {
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.descname, code.descname {
|
||||||
|
padding-right: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.screenshot {
|
||||||
|
-moz-box-shadow: 2px 2px 4px #EEE;
|
||||||
|
-webkit-box-shadow: 2px 2px 4px #EEE;
|
||||||
|
box-shadow: 2px 2px 4px #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.docutils {
|
||||||
|
border: 1px solid #888;
|
||||||
|
-moz-box-shadow: 2px 2px 4px #EEE;
|
||||||
|
-webkit-box-shadow: 2px 2px 4px #EEE;
|
||||||
|
box-shadow: 2px 2px 4px #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.docutils td, table.docutils th {
|
||||||
|
border: 1px solid #888;
|
||||||
|
padding: 0.25em 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.field-list, table.footnote {
|
||||||
|
border: none;
|
||||||
|
-moz-box-shadow: none;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.footnote {
|
||||||
|
margin: 15px 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #EEE;
|
||||||
|
background: #FDFDFD;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.footnote + table.footnote {
|
||||||
|
margin-top: -15px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.field-list th {
|
||||||
|
padding: 0 0.8em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.field-list td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.field-list p {
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cloned from
|
||||||
|
* https://github.com/sphinx-doc/sphinx/commit/ef60dbfce09286b20b7385333d63a60321784e68
|
||||||
|
*/
|
||||||
|
.field-name {
|
||||||
|
-moz-hyphens: manual;
|
||||||
|
-ms-hyphens: manual;
|
||||||
|
-webkit-hyphens: manual;
|
||||||
|
hyphens: manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.footnote td.label {
|
||||||
|
width: .1px;
|
||||||
|
padding: 0.3em 0 0.3em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.footnote td {
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl dd {
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
/* Matches the 30px from the narrow-screen "li > ul" selector below */
|
||||||
|
margin: 10px 0 10px 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #EEE;
|
||||||
|
padding: 7px 30px;
|
||||||
|
margin: 15px 0px;
|
||||||
|
line-height: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.viewcode-block:target {
|
||||||
|
background: #ffd;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl pre, blockquote pre, li pre {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tt, code {
|
||||||
|
background-color: #ecf0f3;
|
||||||
|
color: #222;
|
||||||
|
/* padding: 1px 2px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.xref, code.xref, a tt {
|
||||||
|
background-color: #FBFBFB;
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reference {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted #004B6B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Don't put an underline on images */
|
||||||
|
a.image-reference, a.image-reference:hover {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reference:hover {
|
||||||
|
border-bottom: 1px solid #6D4100;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.footnote-reference {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.7em;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px dotted #004B6B;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.footnote-reference:hover {
|
||||||
|
border-bottom: 1px solid #6D4100;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover tt, a:hover code {
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 870px) {
|
||||||
|
|
||||||
|
div.sphinxsidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.document {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
div.documentwrapper {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.bodywrapper {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li > ul {
|
||||||
|
/* Matches the 30px from the "ul, ol" selector above */
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodywrapper {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 875px) {
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.documentwrapper {
|
||||||
|
float: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar {
|
||||||
|
display: block;
|
||||||
|
float: none;
|
||||||
|
width: 102.5%;
|
||||||
|
margin: 50px -30px -20px -30px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #333;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
|
||||||
|
div.sphinxsidebar h3 a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar a {
|
||||||
|
color: #AAA;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar p.logo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.document {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.bodywrapper {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rtd_doc_footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* misc. */
|
||||||
|
|
||||||
|
.revsys-inline {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make nested-list/multi-paragraph items look better in Releases changelog
|
||||||
|
* pages. Without this, docutils' magical list fuckery causes inconsistent
|
||||||
|
* formatting between different release sub-lists.
|
||||||
|
*/
|
||||||
|
div#changelog > div.section > ul > li > p:only-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide fugly table cell borders in ..bibliography:: directive output */
|
||||||
|
table.docutils.citation, table.docutils.citation td, table.docutils.citation th {
|
||||||
|
border: none;
|
||||||
|
/* Below needed in some edge cases; if not applied, bottom shadows appear */
|
||||||
|
-moz-box-shadow: none;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* relbar */
|
||||||
|
|
||||||
|
.related {
|
||||||
|
line-height: 30px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related.top {
|
||||||
|
border-bottom: 1px solid #EEE;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related.bottom {
|
||||||
|
border-top: 1px solid #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related ul {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav#rellinks {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav#rellinks li+li:before {
|
||||||
|
content: "|";
|
||||||
|
}
|
||||||
|
|
||||||
|
nav#breadcrumbs li+li:before {
|
||||||
|
content: "\00BB";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide certain items when printing */
|
||||||
|
@media print {
|
||||||
|
div.related {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
903
docs/build/html/_static/basic.css
vendored
Normal file
@ -0,0 +1,903 @@
|
|||||||
|
/*
|
||||||
|
* basic.css
|
||||||
|
* ~~~~~~~~~
|
||||||
|
*
|
||||||
|
* Sphinx stylesheet -- basic theme.
|
||||||
|
*
|
||||||
|
* :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS.
|
||||||
|
* :license: BSD, see LICENSE for details.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* -- main layout ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.clearer {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.section::after {
|
||||||
|
display: block;
|
||||||
|
content: '';
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- relbar ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.related {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.related h3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.related ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.related li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.related li.right {
|
||||||
|
float: right;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- sidebar --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.sphinxsidebarwrapper {
|
||||||
|
padding: 10px 5px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar {
|
||||||
|
float: left;
|
||||||
|
width: 230px;
|
||||||
|
margin-left: -100%;
|
||||||
|
font-size: 90%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap : break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul ul,
|
||||||
|
div.sphinxsidebar ul.want-points {
|
||||||
|
margin-left: 20px;
|
||||||
|
list-style: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar ul ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar form {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar input {
|
||||||
|
border: 1px solid #98dbcc;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar #searchbox form.search {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar #searchbox input[type="text"] {
|
||||||
|
float: left;
|
||||||
|
width: 80%;
|
||||||
|
padding: 0.25em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar #searchbox input[type="submit"] {
|
||||||
|
float: left;
|
||||||
|
width: 20%;
|
||||||
|
border-left: none;
|
||||||
|
padding: 0.25em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- search page ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
ul.search {
|
||||||
|
margin: 10px 0 0 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.search li {
|
||||||
|
padding: 5px 0 5px 20px;
|
||||||
|
background-image: url(file.png);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.search li a {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.search li p.context {
|
||||||
|
color: #888;
|
||||||
|
margin: 2px 0 0 30px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.keywordmatches li.goodmatch a {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- index page ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
table.contentstable {
|
||||||
|
width: 90%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.contentstable p.biglink {
|
||||||
|
line-height: 150%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.biglink {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.linkdescr {
|
||||||
|
font-style: italic;
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- general index --------------------------------------------------------- */
|
||||||
|
|
||||||
|
table.indextable {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.indextable td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.indextable ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.indextable > tbody > tr > td > ul {
|
||||||
|
padding-left: 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.indextable tr.pcap {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.indextable tr.cap {
|
||||||
|
margin-top: 10px;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.toggler {
|
||||||
|
margin-right: 3px;
|
||||||
|
margin-top: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.modindex-jumpbox {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin: 1em 0 1em 0;
|
||||||
|
padding: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.genindex-jumpbox {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin: 1em 0 1em 0;
|
||||||
|
padding: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- domain module index --------------------------------------------------- */
|
||||||
|
|
||||||
|
table.modindextable td {
|
||||||
|
padding: 2px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- general body styles --------------------------------------------------- */
|
||||||
|
|
||||||
|
div.body {
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body p, div.body dd, div.body li, div.body blockquote {
|
||||||
|
-moz-hyphens: auto;
|
||||||
|
-ms-hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.headerlink {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:hover > a.headerlink,
|
||||||
|
h2:hover > a.headerlink,
|
||||||
|
h3:hover > a.headerlink,
|
||||||
|
h4:hover > a.headerlink,
|
||||||
|
h5:hover > a.headerlink,
|
||||||
|
h6:hover > a.headerlink,
|
||||||
|
dt:hover > a.headerlink,
|
||||||
|
caption:hover > a.headerlink,
|
||||||
|
p.caption:hover > a.headerlink,
|
||||||
|
div.code-block-caption:hover > a.headerlink {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body p.caption {
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body td {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.rubric {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.align-left, figure.align-left, .figure.align-left, object.align-left {
|
||||||
|
clear: left;
|
||||||
|
float: left;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.align-right, figure.align-right, .figure.align-right, object.align-right {
|
||||||
|
clear: right;
|
||||||
|
float: right;
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.align-center, figure.align-center, .figure.align-center, object.align-center {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.align-default, figure.align-default, .figure.align-default {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-default {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- sidebars -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.sidebar,
|
||||||
|
aside.sidebar {
|
||||||
|
margin: 0 0 0.5em 1em;
|
||||||
|
border: 1px solid #ddb;
|
||||||
|
padding: 7px;
|
||||||
|
background-color: #ffe;
|
||||||
|
width: 40%;
|
||||||
|
float: right;
|
||||||
|
clear: right;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sidebar-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.contents,
|
||||||
|
aside.topic,
|
||||||
|
div.admonition, div.topic, blockquote {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- topics ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
nav.contents,
|
||||||
|
aside.topic,
|
||||||
|
div.topic {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 7px;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.topic-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- admonitions ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.admonition {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.admonition dt {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.admonition-title {
|
||||||
|
margin: 0px 10px 5px 0px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body p.centered {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- content of sidebars/topics/admonitions -------------------------------- */
|
||||||
|
|
||||||
|
div.sidebar > :last-child,
|
||||||
|
aside.sidebar > :last-child,
|
||||||
|
nav.contents > :last-child,
|
||||||
|
aside.topic > :last-child,
|
||||||
|
div.topic > :last-child,
|
||||||
|
div.admonition > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sidebar::after,
|
||||||
|
aside.sidebar::after,
|
||||||
|
nav.contents::after,
|
||||||
|
aside.topic::after,
|
||||||
|
div.topic::after,
|
||||||
|
div.admonition::after,
|
||||||
|
blockquote::after {
|
||||||
|
display: block;
|
||||||
|
content: '';
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- tables ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
table.docutils {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.align-center {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.align-default {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table caption span.caption-number {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
table caption span.caption-text {
|
||||||
|
}
|
||||||
|
|
||||||
|
table.docutils td, table.docutils th {
|
||||||
|
padding: 1px 8px 1px 5px;
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.citation {
|
||||||
|
border-left: solid 1px gray;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.citation td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th > :first-child,
|
||||||
|
td > :first-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th > :last-child,
|
||||||
|
td > :last-child {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- figures --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
div.figure, figure {
|
||||||
|
margin: 0.5em;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.figure p.caption, figcaption {
|
||||||
|
padding: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.figure p.caption span.caption-number,
|
||||||
|
figcaption span.caption-number {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.figure p.caption span.caption-text,
|
||||||
|
figcaption span.caption-text {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- field list styles ----------------------------------------------------- */
|
||||||
|
|
||||||
|
table.field-list td, table.field-list th {
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-list ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-list p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
-moz-hyphens: manual;
|
||||||
|
-ms-hyphens: manual;
|
||||||
|
-webkit-hyphens: manual;
|
||||||
|
hyphens: manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- hlist styles ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
table.hlist {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.hlist td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- object description styles --------------------------------------------- */
|
||||||
|
|
||||||
|
.sig {
|
||||||
|
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-name, code.descname {
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-name {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code.descname {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-prename, code.descclassname {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-paren {
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-param.n {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* C++ specific styling */
|
||||||
|
|
||||||
|
.sig-inline.c-texpr,
|
||||||
|
.sig-inline.cpp-texpr {
|
||||||
|
font-family: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig.c .k, .sig.c .kt,
|
||||||
|
.sig.cpp .k, .sig.cpp .kt {
|
||||||
|
color: #0033B3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig.c .m,
|
||||||
|
.sig.cpp .m {
|
||||||
|
color: #1750EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig.c .s, .sig.c .sc,
|
||||||
|
.sig.cpp .s, .sig.cpp .sc {
|
||||||
|
color: #067D17;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -- other body styles ----------------------------------------------------- */
|
||||||
|
|
||||||
|
ol.arabic {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.loweralpha {
|
||||||
|
list-style: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.upperalpha {
|
||||||
|
list-style: upper-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.lowerroman {
|
||||||
|
list-style: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.upperroman {
|
||||||
|
list-style: upper-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(li) > ol > li:first-child > :first-child,
|
||||||
|
:not(li) > ul > li:first-child > :first-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(li) > ol > li:last-child > :last-child,
|
||||||
|
:not(li) > ul > li:last-child > :last-child {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.simple ol p,
|
||||||
|
ol.simple ul p,
|
||||||
|
ul.simple ol p,
|
||||||
|
ul.simple ul p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.simple > li:not(:first-child) > p,
|
||||||
|
ul.simple > li:not(:first-child) > p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.simple p,
|
||||||
|
ul.simple p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.footnote > span,
|
||||||
|
div.citation > span {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
aside.footnote > span:last-of-type,
|
||||||
|
div.citation > span:last-of-type {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
aside.footnote > p {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
div.citation > p {
|
||||||
|
margin-left: 4em;
|
||||||
|
}
|
||||||
|
aside.footnote > p:last-of-type,
|
||||||
|
div.citation > p:last-of-type {
|
||||||
|
margin-bottom: 0em;
|
||||||
|
}
|
||||||
|
aside.footnote > p:last-of-type:after,
|
||||||
|
div.citation > p:last-of-type:after {
|
||||||
|
content: "";
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.field-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: fit-content(30%) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.field-list > dt {
|
||||||
|
font-weight: bold;
|
||||||
|
word-break: break-word;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.field-list > dd {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
margin-top: 0em;
|
||||||
|
margin-left: 0em;
|
||||||
|
margin-bottom: 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd > :first-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd ul, dd table {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl > dd:last-child,
|
||||||
|
dl > dd:last-child > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt:target, span.highlighted {
|
||||||
|
background-color: #fbe54e;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.highlighted {
|
||||||
|
fill: #fbe54e;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.glossary dt {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versionmodified {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
background-color: #fda;
|
||||||
|
padding: 5px;
|
||||||
|
border: 3px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote:target {
|
||||||
|
background-color: #ffa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-block {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-block .line-block {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guilabel, .menuselection {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accelerator {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classifier {
|
||||||
|
font-style: oblique;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classifier:before {
|
||||||
|
font-style: normal;
|
||||||
|
margin: 0 0.5em;
|
||||||
|
content: ":";
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr, acronym {
|
||||||
|
border-bottom: dotted 1px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- code displays --------------------------------------------------------- */
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
overflow-y: hidden; /* fixes display issues on Chrome browsers */
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, div[class*="highlight-"] {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.pre {
|
||||||
|
-moz-hyphens: none;
|
||||||
|
-ms-hyphens: none;
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
hyphens: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[class*="highlight-"] {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.linenos pre {
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable tbody {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable tr {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable td {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable td.linenos {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable td.code {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight .hll {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.highlight pre,
|
||||||
|
table.highlighttable pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.code-block-caption + div {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.code-block-caption {
|
||||||
|
margin-top: 1em;
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.code-block-caption code {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.highlighttable td.linenos,
|
||||||
|
span.linenos,
|
||||||
|
div.highlight span.gp { /* gp: Generic.Prompt */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: text; /* Safari fallback only */
|
||||||
|
-webkit-user-select: none; /* Chrome/Safari */
|
||||||
|
-moz-user-select: none; /* Firefox */
|
||||||
|
-ms-user-select: none; /* IE10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
div.code-block-caption span.caption-number {
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.code-block-caption span.caption-text {
|
||||||
|
}
|
||||||
|
|
||||||
|
div.literal-block-wrapper {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code.xref, a code {
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewcode-link {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewcode-back {
|
||||||
|
float: right;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.viewcode-block:target {
|
||||||
|
margin: -1px -10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- math display ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
img.math {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.body div.math p {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.eqno {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.eqno a.headerlink {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.math:hover a.headerlink {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- printout stylesheet --------------------------------------------------- */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
div.document,
|
||||||
|
div.documentwrapper,
|
||||||
|
div.bodywrapper {
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sphinxsidebar,
|
||||||
|
div.related,
|
||||||
|
div.footer,
|
||||||
|
#top-link {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
docs/build/html/_static/custom.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* This file intentionally left blank. */
|
||||||
69
docs/build/html/_static/debug.css
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
This CSS file should be overridden by the theme authors. It's
|
||||||
|
meant for debugging and developing the skeleton that this theme provides.
|
||||||
|
*/
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
background: lavender;
|
||||||
|
}
|
||||||
|
.sb-announcement {
|
||||||
|
background: rgb(131, 131, 131);
|
||||||
|
}
|
||||||
|
.sb-announcement__inner {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.sb-header {
|
||||||
|
background: lightskyblue;
|
||||||
|
}
|
||||||
|
.sb-header__inner {
|
||||||
|
background: royalblue;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.sb-header-secondary {
|
||||||
|
background: lightcyan;
|
||||||
|
}
|
||||||
|
.sb-header-secondary__inner {
|
||||||
|
background: cornflowerblue;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.sb-sidebar-primary {
|
||||||
|
background: lightgreen;
|
||||||
|
}
|
||||||
|
.sb-main {
|
||||||
|
background: blanchedalmond;
|
||||||
|
}
|
||||||
|
.sb-main__inner {
|
||||||
|
background: antiquewhite;
|
||||||
|
}
|
||||||
|
.sb-header-article {
|
||||||
|
background: lightsteelblue;
|
||||||
|
}
|
||||||
|
.sb-article-container {
|
||||||
|
background: snow;
|
||||||
|
}
|
||||||
|
.sb-article-main {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.sb-footer-article {
|
||||||
|
background: lightpink;
|
||||||
|
}
|
||||||
|
.sb-sidebar-secondary {
|
||||||
|
background: lightgoldenrodyellow;
|
||||||
|
}
|
||||||
|
.sb-footer-content {
|
||||||
|
background: plum;
|
||||||
|
}
|
||||||
|
.sb-footer-content__inner {
|
||||||
|
background: palevioletred;
|
||||||
|
}
|
||||||
|
.sb-footer {
|
||||||
|
background: pink;
|
||||||
|
}
|
||||||
|
.sb-footer__inner {
|
||||||
|
background: salmon;
|
||||||
|
}
|
||||||
|
.sb-article {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
156
docs/build/html/_static/doctools.js
vendored
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
/*
|
||||||
|
* doctools.js
|
||||||
|
* ~~~~~~~~~~~
|
||||||
|
*
|
||||||
|
* Base JavaScript utilities for all Sphinx HTML documentation.
|
||||||
|
*
|
||||||
|
* :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS.
|
||||||
|
* :license: BSD, see LICENSE for details.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([
|
||||||
|
"TEXTAREA",
|
||||||
|
"INPUT",
|
||||||
|
"SELECT",
|
||||||
|
"BUTTON",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const _ready = (callback) => {
|
||||||
|
if (document.readyState !== "loading") {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
document.addEventListener("DOMContentLoaded", callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small JavaScript module for the documentation.
|
||||||
|
*/
|
||||||
|
const Documentation = {
|
||||||
|
init: () => {
|
||||||
|
Documentation.initDomainIndexTable();
|
||||||
|
Documentation.initOnKeyListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* i18n support
|
||||||
|
*/
|
||||||
|
TRANSLATIONS: {},
|
||||||
|
PLURAL_EXPR: (n) => (n === 1 ? 0 : 1),
|
||||||
|
LOCALE: "unknown",
|
||||||
|
|
||||||
|
// gettext and ngettext don't access this so that the functions
|
||||||
|
// can safely bound to a different name (_ = Documentation.gettext)
|
||||||
|
gettext: (string) => {
|
||||||
|
const translated = Documentation.TRANSLATIONS[string];
|
||||||
|
switch (typeof translated) {
|
||||||
|
case "undefined":
|
||||||
|
return string; // no translation
|
||||||
|
case "string":
|
||||||
|
return translated; // translation exists
|
||||||
|
default:
|
||||||
|
return translated[0]; // (singular, plural) translation tuple exists
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
ngettext: (singular, plural, n) => {
|
||||||
|
const translated = Documentation.TRANSLATIONS[singular];
|
||||||
|
if (typeof translated !== "undefined")
|
||||||
|
return translated[Documentation.PLURAL_EXPR(n)];
|
||||||
|
return n === 1 ? singular : plural;
|
||||||
|
},
|
||||||
|
|
||||||
|
addTranslations: (catalog) => {
|
||||||
|
Object.assign(Documentation.TRANSLATIONS, catalog.messages);
|
||||||
|
Documentation.PLURAL_EXPR = new Function(
|
||||||
|
"n",
|
||||||
|
`return (${catalog.plural_expr})`
|
||||||
|
);
|
||||||
|
Documentation.LOCALE = catalog.locale;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* helper function to focus on search bar
|
||||||
|
*/
|
||||||
|
focusSearchBar: () => {
|
||||||
|
document.querySelectorAll("input[name=q]")[0]?.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the domain index toggle buttons
|
||||||
|
*/
|
||||||
|
initDomainIndexTable: () => {
|
||||||
|
const toggler = (el) => {
|
||||||
|
const idNumber = el.id.substr(7);
|
||||||
|
const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`);
|
||||||
|
if (el.src.substr(-9) === "minus.png") {
|
||||||
|
el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`;
|
||||||
|
toggledRows.forEach((el) => (el.style.display = "none"));
|
||||||
|
} else {
|
||||||
|
el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`;
|
||||||
|
toggledRows.forEach((el) => (el.style.display = ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglerElements = document.querySelectorAll("img.toggler");
|
||||||
|
togglerElements.forEach((el) =>
|
||||||
|
el.addEventListener("click", (event) => toggler(event.currentTarget))
|
||||||
|
);
|
||||||
|
togglerElements.forEach((el) => (el.style.display = ""));
|
||||||
|
if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler);
|
||||||
|
},
|
||||||
|
|
||||||
|
initOnKeyListeners: () => {
|
||||||
|
// only install a listener if it is really needed
|
||||||
|
if (
|
||||||
|
!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS &&
|
||||||
|
!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
// bail for input elements
|
||||||
|
if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return;
|
||||||
|
// bail with special keys
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break;
|
||||||
|
|
||||||
|
const prevLink = document.querySelector('link[rel="prev"]');
|
||||||
|
if (prevLink && prevLink.href) {
|
||||||
|
window.location.href = prevLink.href;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break;
|
||||||
|
|
||||||
|
const nextLink = document.querySelector('link[rel="next"]');
|
||||||
|
if (nextLink && nextLink.href) {
|
||||||
|
window.location.href = nextLink.href;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// some keyboard layouts may need Shift to get /
|
||||||
|
switch (event.key) {
|
||||||
|
case "/":
|
||||||
|
if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break;
|
||||||
|
Documentation.focusSearchBar();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// quick alias for translations
|
||||||
|
const _ = Documentation.gettext;
|
||||||
|
|
||||||
|
_ready(Documentation.init);
|
||||||
14
docs/build/html/_static/documentation_options.js
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
var DOCUMENTATION_OPTIONS = {
|
||||||
|
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||||
|
VERSION: '2.11.2',
|
||||||
|
LANGUAGE: 'en',
|
||||||
|
COLLAPSE_INDEX: false,
|
||||||
|
BUILDER: 'html',
|
||||||
|
FILE_SUFFIX: '.html',
|
||||||
|
LINK_SUFFIX: '.html',
|
||||||
|
HAS_SOURCE: true,
|
||||||
|
SOURCELINK_SUFFIX: '.txt',
|
||||||
|
NAVIGATION_WITH_KEYS: false,
|
||||||
|
SHOW_SEARCH_SUMMARY: true,
|
||||||
|
ENABLE_SEARCH_SHORTCUTS: true,
|
||||||
|
};
|
||||||
BIN
docs/build/html/_static/file.png
vendored
Normal file
|
After Width: | Height: | Size: 286 B |
199
docs/build/html/_static/language_data.js
vendored
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* language_data.js
|
||||||
|
* ~~~~~~~~~~~~~~~~
|
||||||
|
*
|
||||||
|
* This script contains the language-specific data used by searchtools.js,
|
||||||
|
* namely the list of stopwords, stemmer, scorer and splitter.
|
||||||
|
*
|
||||||
|
* :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS.
|
||||||
|
* :license: BSD, see LICENSE for details.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
var stopwords = ["a", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "near", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"];
|
||||||
|
|
||||||
|
|
||||||
|
/* Non-minified version is copied as a separate JS file, is available */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Porter Stemmer
|
||||||
|
*/
|
||||||
|
var Stemmer = function() {
|
||||||
|
|
||||||
|
var step2list = {
|
||||||
|
ational: 'ate',
|
||||||
|
tional: 'tion',
|
||||||
|
enci: 'ence',
|
||||||
|
anci: 'ance',
|
||||||
|
izer: 'ize',
|
||||||
|
bli: 'ble',
|
||||||
|
alli: 'al',
|
||||||
|
entli: 'ent',
|
||||||
|
eli: 'e',
|
||||||
|
ousli: 'ous',
|
||||||
|
ization: 'ize',
|
||||||
|
ation: 'ate',
|
||||||
|
ator: 'ate',
|
||||||
|
alism: 'al',
|
||||||
|
iveness: 'ive',
|
||||||
|
fulness: 'ful',
|
||||||
|
ousness: 'ous',
|
||||||
|
aliti: 'al',
|
||||||
|
iviti: 'ive',
|
||||||
|
biliti: 'ble',
|
||||||
|
logi: 'log'
|
||||||
|
};
|
||||||
|
|
||||||
|
var step3list = {
|
||||||
|
icate: 'ic',
|
||||||
|
ative: '',
|
||||||
|
alize: 'al',
|
||||||
|
iciti: 'ic',
|
||||||
|
ical: 'ic',
|
||||||
|
ful: '',
|
||||||
|
ness: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
var c = "[^aeiou]"; // consonant
|
||||||
|
var v = "[aeiouy]"; // vowel
|
||||||
|
var C = c + "[^aeiouy]*"; // consonant sequence
|
||||||
|
var V = v + "[aeiou]*"; // vowel sequence
|
||||||
|
|
||||||
|
var mgr0 = "^(" + C + ")?" + V + C; // [C]VC... is m>0
|
||||||
|
var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1
|
||||||
|
var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1
|
||||||
|
var s_v = "^(" + C + ")?" + v; // vowel in stem
|
||||||
|
|
||||||
|
this.stemWord = function (w) {
|
||||||
|
var stem;
|
||||||
|
var suffix;
|
||||||
|
var firstch;
|
||||||
|
var origword = w;
|
||||||
|
|
||||||
|
if (w.length < 3)
|
||||||
|
return w;
|
||||||
|
|
||||||
|
var re;
|
||||||
|
var re2;
|
||||||
|
var re3;
|
||||||
|
var re4;
|
||||||
|
|
||||||
|
firstch = w.substr(0,1);
|
||||||
|
if (firstch == "y")
|
||||||
|
w = firstch.toUpperCase() + w.substr(1);
|
||||||
|
|
||||||
|
// Step 1a
|
||||||
|
re = /^(.+?)(ss|i)es$/;
|
||||||
|
re2 = /^(.+?)([^s])s$/;
|
||||||
|
|
||||||
|
if (re.test(w))
|
||||||
|
w = w.replace(re,"$1$2");
|
||||||
|
else if (re2.test(w))
|
||||||
|
w = w.replace(re2,"$1$2");
|
||||||
|
|
||||||
|
// Step 1b
|
||||||
|
re = /^(.+?)eed$/;
|
||||||
|
re2 = /^(.+?)(ed|ing)$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
re = new RegExp(mgr0);
|
||||||
|
if (re.test(fp[1])) {
|
||||||
|
re = /.$/;
|
||||||
|
w = w.replace(re,"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (re2.test(w)) {
|
||||||
|
var fp = re2.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
re2 = new RegExp(s_v);
|
||||||
|
if (re2.test(stem)) {
|
||||||
|
w = stem;
|
||||||
|
re2 = /(at|bl|iz)$/;
|
||||||
|
re3 = new RegExp("([^aeiouylsz])\\1$");
|
||||||
|
re4 = new RegExp("^" + C + v + "[^aeiouwxy]$");
|
||||||
|
if (re2.test(w))
|
||||||
|
w = w + "e";
|
||||||
|
else if (re3.test(w)) {
|
||||||
|
re = /.$/;
|
||||||
|
w = w.replace(re,"");
|
||||||
|
}
|
||||||
|
else if (re4.test(w))
|
||||||
|
w = w + "e";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1c
|
||||||
|
re = /^(.+?)y$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
re = new RegExp(s_v);
|
||||||
|
if (re.test(stem))
|
||||||
|
w = stem + "i";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
suffix = fp[2];
|
||||||
|
re = new RegExp(mgr0);
|
||||||
|
if (re.test(stem))
|
||||||
|
w = stem + step2list[suffix];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
suffix = fp[2];
|
||||||
|
re = new RegExp(mgr0);
|
||||||
|
if (re.test(stem))
|
||||||
|
w = stem + step3list[suffix];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4
|
||||||
|
re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
|
||||||
|
re2 = /^(.+?)(s|t)(ion)$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
re = new RegExp(mgr1);
|
||||||
|
if (re.test(stem))
|
||||||
|
w = stem;
|
||||||
|
}
|
||||||
|
else if (re2.test(w)) {
|
||||||
|
var fp = re2.exec(w);
|
||||||
|
stem = fp[1] + fp[2];
|
||||||
|
re2 = new RegExp(mgr1);
|
||||||
|
if (re2.test(stem))
|
||||||
|
w = stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5
|
||||||
|
re = /^(.+?)e$/;
|
||||||
|
if (re.test(w)) {
|
||||||
|
var fp = re.exec(w);
|
||||||
|
stem = fp[1];
|
||||||
|
re = new RegExp(mgr1);
|
||||||
|
re2 = new RegExp(meq1);
|
||||||
|
re3 = new RegExp("^" + C + v + "[^aeiouwxy]$");
|
||||||
|
if (re.test(stem) || (re2.test(stem) && !(re3.test(stem))))
|
||||||
|
w = stem;
|
||||||
|
}
|
||||||
|
re = /ll$/;
|
||||||
|
re2 = new RegExp(mgr1);
|
||||||
|
if (re.test(w) && re2.test(w)) {
|
||||||
|
re = /.$/;
|
||||||
|
w = w.replace(re,"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// and turn initial Y back to y
|
||||||
|
if (firstch == "y")
|
||||||
|
w = firstch.toLowerCase() + w.substr(1);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||