Compare commits
663 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c927c56c0 | ||
|
|
3a7672982f | ||
|
|
a3ddca05a3 | ||
|
|
e9dba716b7 | ||
|
|
6ed5d0cb5f | ||
|
|
44819de3a5 | ||
|
|
043ad12dec | ||
|
|
cc5facdf4f | ||
|
|
0a603ad507 | ||
|
|
05a193fb4b | ||
|
|
5e604c5bac | ||
|
|
bfd1b313ab | ||
|
|
5c095b2395 | ||
|
|
5fc3348cfc | ||
|
|
42e44caf0d | ||
|
|
2e864eed7c | ||
|
|
f249edbd6a | ||
|
|
382bc7a620 | ||
|
|
a0eddd429e | ||
|
|
e60dc12a12 | ||
|
|
c9384aac08 | ||
|
|
9d9939be9f | ||
|
|
e25e1748c4 | ||
|
|
4d5d120e39 | ||
|
|
9c85daf712 | ||
|
|
01ed21f83d | ||
|
|
b3af44f42c | ||
|
|
9306a50123 | ||
|
|
54d2a5f0af | ||
|
|
8c8e8de142 | ||
|
|
20fe4739b5 | ||
|
|
774ac81b4b | ||
|
|
10a529220c | ||
|
|
7c9d48a9fa | ||
|
|
a76d526569 | ||
|
|
e2d71266c5 | ||
|
|
86110a2e65 | ||
|
|
4330f61888 | ||
|
|
4951cea269 | ||
|
|
ed1a4e77f6 | ||
|
|
40a3e24071 | ||
|
|
daeb0ae4b6 | ||
|
|
02d9987ad7 | ||
|
|
aa7b25cd33 | ||
|
|
e42537d591 | ||
|
|
410d4452d1 | ||
|
|
092dd8a532 | ||
|
|
2b6619b4da | ||
|
|
db8b90487f | ||
|
|
dec21aa57c | ||
|
|
baf9a83e50 | ||
|
|
d214cc8df3 | ||
|
|
55b8b4e966 | ||
|
|
6e8830c4e6 | ||
|
|
609cb4f10f | ||
|
|
917c6d21c8 | ||
|
|
2095ea0d45 | ||
|
|
ec8099b7a0 | ||
|
|
b077dbedce | ||
|
|
e6b030e7f1 | ||
|
|
5145590b1e | ||
|
|
329c6b26bd | ||
|
|
44860d495e | ||
|
|
7c9576874b | ||
|
|
3110c2221b | ||
|
|
a428f377d4 | ||
|
|
c1915fb6b1 | ||
|
|
cd54dae176 | ||
|
|
c72a619df0 | ||
|
|
425214d453 | ||
|
|
10693e103e | ||
|
|
8f045ceaf3 | ||
|
|
ff883142f7 | ||
|
|
91f1586ab0 | ||
|
|
cef5278f16 | ||
|
|
c923ad00f8 | ||
|
|
ec3dc578b8 | ||
|
|
c5e0d4f3ca | ||
|
|
53c6c20d5e | ||
|
|
1ffc8e7175 | ||
|
|
1c8d5fe423 | ||
|
|
6299ad3b55 | ||
|
|
e4efa0d879 | ||
|
|
2ebc0f11d2 | ||
|
|
180f28800e | ||
|
|
7f5692d6cd | ||
|
|
8d7b5337eb | ||
|
|
dcbf4330be | ||
|
|
2e53f20d80 | ||
|
|
2914ddcc36 | ||
|
|
442756e3cc | ||
|
|
b55a2a67c9 | ||
|
|
09545c7015 | ||
|
|
1f82efa2a1 | ||
|
|
e509052242 | ||
|
|
046cdade5a | ||
|
|
7f8e639b86 | ||
|
|
bfbd36f7f9 | ||
|
|
c5d623547c | ||
|
|
31c61675bf | ||
|
|
9900bd9ee9 | ||
|
|
8459494c61 | ||
|
|
9852feec81 | ||
|
|
ce79c0f0f7 | ||
|
|
51037fc714 | ||
|
|
f6b9178688 | ||
|
|
7ccbd6ce79 | ||
|
|
30926c6b79 | ||
|
|
270076b9a7 | ||
|
|
ba58d45d8b | ||
|
|
0543c92f37 | ||
|
|
f40a4c5c7b | ||
|
|
cc05933992 | ||
|
|
896eae92ff | ||
|
|
07676e8c5d | ||
|
|
4ec70a210b | ||
|
|
0b395f26ed | ||
|
|
965a8be5bb | ||
|
|
36ddfc8885 | ||
|
|
f82957c73f | ||
|
|
f1625e7d92 | ||
|
|
48797ddf8f | ||
|
|
6f67f515b2 | ||
|
|
9a0146b04e | ||
|
|
7fdb28c352 | ||
|
|
5619584481 | ||
|
|
3f0a1cb8f5 | ||
|
|
2f6105843b | ||
|
|
d71f99de53 | ||
|
|
d7385405d9 | ||
|
|
781e5ebb2f | ||
|
|
980772bf9c | ||
|
|
632e411c6e | ||
|
|
789d442029 | ||
|
|
0452d49930 | ||
|
|
19d781fa1f | ||
|
|
ad82d13a7e | ||
|
|
9c4d757dc0 | ||
|
|
e369d80875 | ||
|
|
9b0c8510a3 | ||
|
|
94fb158d7d | ||
|
|
11df6e9f0c | ||
|
|
4e8550a56c | ||
|
|
7f349410a0 | ||
|
|
6610c29fe4 | ||
|
|
3da1b8eac1 | ||
|
|
003068e62c | ||
|
|
1d12f0d508 | ||
|
|
1ee4667a79 | ||
|
|
521605e9c8 | ||
|
|
9add14408c | ||
|
|
e554ace7ae | ||
|
|
cdd0cdd237 | ||
|
|
c713d32230 | ||
|
|
8ee4e88f82 | ||
|
|
ca7679e9d3 | ||
|
|
b8c18ef33b | ||
|
|
1ce324151e | ||
|
|
faa452253c | ||
|
|
ae41ae57b3 | ||
|
|
6d3fe9381d | ||
|
|
f879c0aeb9 | ||
|
|
58ffb93d3f | ||
|
|
8e5df7094c | ||
|
|
64c2f54ff0 | ||
|
|
d1b869ae43 | ||
|
|
d3895f2632 | ||
|
|
5bf62c4b1a | ||
|
|
406e09922c | ||
|
|
ae34572d13 | ||
|
|
1e3c69ea90 | ||
|
|
3c232505f8 | ||
|
|
44177db9b6 | ||
|
|
e72ae973bc | ||
|
|
4ab3c5cbee | ||
|
|
4e532d298d | ||
|
|
3372440f4e | ||
|
|
1255239912 | ||
|
|
e401a73595 | ||
|
|
cca6e47da5 | ||
|
|
415e75d4b4 | ||
|
|
3c5573a2fc | ||
|
|
7275b59d40 | ||
|
|
a8d0631c33 | ||
|
|
3cfc96b779 | ||
|
|
489f3f1d6f | ||
|
|
a5f2fc195e | ||
|
|
393dbabf4b | ||
|
|
444e697f9d | ||
|
|
cf01039b53 | ||
|
|
a8fefc6f82 | ||
|
|
ae0b6066d9 | ||
|
|
53f5e7db8c | ||
|
|
2a1fa9f8cf | ||
|
|
6e2d674758 | ||
|
|
3bb6573ec0 | ||
|
|
4ae3774a0e | ||
|
|
b34215560c | ||
|
|
4426017ba8 | ||
|
|
f1635f8e32 | ||
|
|
4de032e193 | ||
|
|
d655157e1d | ||
|
|
ff06958ab3 | ||
|
|
6e98b5ee2b | ||
|
|
da90fe2633 | ||
|
|
eaedef452c | ||
|
|
2d5f3799a3 | ||
|
|
b52bfe0848 | ||
|
|
e2261b2d19 | ||
|
|
2443444165 | ||
|
|
7c912a51be | ||
|
|
bda55a1faa | ||
|
|
5b5f957f8e | ||
|
|
12f54e3ad4 | ||
|
|
d6594e1270 | ||
|
|
b678447417 | ||
|
|
02508f6997 | ||
|
|
d50fff9e31 | ||
|
|
2d7d7ddc95 | ||
|
|
9c9825d423 | ||
|
|
f788c0f37b | ||
|
|
36d72d1eca | ||
|
|
6b17779c59 | ||
|
|
759130e38d | ||
|
|
49f727477e | ||
|
|
43886116c1 | ||
|
|
a38194027d | ||
|
|
eab4f4bd46 | ||
|
|
6b38676766 | ||
|
|
3830f58c0b | ||
|
|
34cdea1731 | ||
|
|
22f545b99c | ||
|
|
37f73dc31f | ||
|
|
10e52f0b63 | ||
|
|
b0ac1034d2 | ||
|
|
48d663a764 | ||
|
|
08ab75b390 | ||
|
|
71dfc6d468 | ||
|
|
7877775495 | ||
|
|
7744a75773 | ||
|
|
a28a0267e4 | ||
|
|
417a5c924a | ||
|
|
d4bc1c49ea | ||
|
|
51ef9e08fe | ||
|
|
285203a342 | ||
|
|
af6077693e | ||
|
|
40ac67cce0 | ||
|
|
96d03c5c29 | ||
|
|
82aa453e50 | ||
|
|
1718211619 | ||
|
|
09310675fc | ||
|
|
ca711bbdb8 | ||
|
|
fb44f88df2 | ||
|
|
e5b32b2831 | ||
|
|
19ddbb7ca9 | ||
|
|
3e82d7acdc | ||
|
|
3921c70c86 | ||
|
|
aea9333e57 | ||
|
|
7043ca31cf | ||
|
|
963d86de7c | ||
|
|
d6b0e974b7 | ||
|
|
8a8340a159 | ||
|
|
20d3c9fce9 | ||
|
|
729549a7a9 | ||
|
|
d28614177c | ||
|
|
db53517784 | ||
|
|
186e9c1417 | ||
|
|
7498016d61 | ||
|
|
7114ea2e6e | ||
|
|
e8ca1f8678 | ||
|
|
2275bc2600 | ||
|
|
45a94f4bfe | ||
|
|
5cb2c84715 | ||
|
|
a3bf8d8aaa | ||
|
|
0a88b7be26 | ||
|
|
05da3e8fc4 | ||
|
|
560ea1aeca | ||
|
|
5ef1f2d940 | ||
|
|
e01fcdcecd | ||
|
|
8ff04dd0f6 | ||
|
|
4a7b5ac2b0 | ||
|
|
ac74ef3c15 | ||
|
|
8913057c27 | ||
|
|
a75030a73c | ||
|
|
e2e74f9459 | ||
|
|
09febb66dc | ||
|
|
ad0db8c9a9 | ||
|
|
90b0fecd3b | ||
|
|
a8f88e8bfc | ||
|
|
d8035f0713 | ||
|
|
76c1a8952f | ||
|
|
d8a66ca152 | ||
|
|
dd77176035 | ||
|
|
6d7d90d642 | ||
|
|
514bf9b8b2 | ||
|
|
cd5869ff84 | ||
|
|
ee6f4ee4e4 | ||
|
|
0189a00155 | ||
|
|
e3d60d3f2e | ||
|
|
27fecd788b | ||
|
|
e9b1d94fb3 | ||
|
|
043a9303a5 | ||
|
|
aa698667c9 | ||
|
|
700e17854c | ||
|
|
40258c9fa1 | ||
|
|
eb59bf0db5 | ||
|
|
a9df78af97 | ||
|
|
901506a32d | ||
|
|
0ddbe58fbd | ||
|
|
350d82184f | ||
|
|
5b8054abd9 | ||
|
|
c5c691b653 | ||
|
|
7a44c8587c | ||
|
|
5092ffc91a | ||
|
|
2da12e12d5 | ||
|
|
5aff72dbb6 | ||
|
|
043f452e71 | ||
|
|
5cedf8a907 | ||
|
|
ae77c7232e | ||
|
|
d559413d46 | ||
|
|
8b185d8768 | ||
|
|
202105a11f | ||
|
|
7da80594e3 | ||
|
|
a032a1d50a | ||
|
|
be62d8abc8 | ||
|
|
aaf9f4b6a7 | ||
|
|
bb5d82097e | ||
|
|
d6ba656641 | ||
|
|
742cae0543 | ||
|
|
a4ab1af160 | ||
|
|
86f158532a | ||
|
|
9ea7935cfb | ||
|
|
20c7e14076 | ||
|
|
e10f5b89b6 | ||
|
|
cb0bae5ae7 | ||
|
|
6a583119d0 | ||
|
|
9349070e8b | ||
|
|
01d99dc699 | ||
|
|
f4d9a6c1fd | ||
|
|
1919bc84d9 | ||
|
|
4c1761468f | ||
|
|
c75230a67d | ||
|
|
ee7ffa55cb | ||
|
|
45bbc8af42 | ||
|
|
ab8bf8fa62 | ||
|
|
493cad080e | ||
|
|
eaa159c5cb | ||
|
|
96e3b8c2ff | ||
|
|
794e8dcd06 | ||
|
|
f2675e4340 | ||
|
|
e5059840fb | ||
|
|
f3e57789fa | ||
|
|
c151b826f7 | ||
|
|
1b3ed3b35a | ||
|
|
77a37a9438 | ||
|
|
f22de9f906 | ||
|
|
090e8c3f4c | ||
|
|
aef51fb65d | ||
|
|
a5a3a2dc62 | ||
|
|
ce9adcee7f | ||
|
|
485a42a9a0 | ||
|
|
3754970c84 | ||
|
|
924fbc208b | ||
|
|
c1a2ee4577 | ||
|
|
e84ee5de1e | ||
|
|
0414830539 | ||
|
|
385f23842d | ||
|
|
1deceaa5a5 | ||
|
|
b8f1157e27 | ||
|
|
06558c24b7 | ||
|
|
6bd399b654 | ||
|
|
228d89f1f8 | ||
|
|
e97639302f | ||
|
|
7f4e51be08 | ||
|
|
cdab5fc92d | ||
|
|
6efe498f2a | ||
|
|
0f3f2e47f5 | ||
|
|
3b720b7367 | ||
|
|
9a3e513b6c | ||
|
|
5a8e5dfa82 | ||
|
|
70d9ad93cb | ||
|
|
87b3411f5e | ||
|
|
5c88c79ac6 | ||
|
|
5df91e7a59 | ||
|
|
71e9c15b5d | ||
|
|
c151144a5a | ||
|
|
82a3373e72 | ||
|
|
29ef5f238f | ||
|
|
1809a2ab54 | ||
|
|
3b1684f553 | ||
|
|
d088374333 | ||
|
|
80da6bd1e6 | ||
|
|
619bb9c853 | ||
|
|
5add7b7a5c | ||
|
|
f61f14e16c | ||
|
|
125421db22 | ||
|
|
98b73f72df | ||
|
|
e68bc3b937 | ||
|
|
ab447a4633 | ||
|
|
23d321a722 | ||
|
|
20945954b1 | ||
|
|
d6c2078917 | ||
|
|
a5b8dc639c | ||
|
|
84751e0d68 | ||
|
|
e759658481 | ||
|
|
d83100588f | ||
|
|
83d0e02eb4 | ||
|
|
20f1f33b6c | ||
|
|
fbb5d26c28 | ||
|
|
2c21778675 | ||
|
|
959acd8fb7 | ||
|
|
148c5b7621 | ||
|
|
d7a2afba48 | ||
|
|
559ed23214 | ||
|
|
1d1e63df8a | ||
|
|
27e9d92a0a | ||
|
|
69573f3fa4 | ||
|
|
edafd79140 | ||
|
|
7c45d93fea | ||
|
|
f19a7e1080 | ||
|
|
5e0ae07978 | ||
|
|
116a05ce4b | ||
|
|
d6fdee5905 | ||
|
|
af25300917 | ||
|
|
f18b2f49bf | ||
|
|
db1e4e6fc4 | ||
|
|
dc49f63a37 | ||
|
|
6837841872 | ||
|
|
637347ae0c | ||
|
|
ff968c4db4 | ||
|
|
ca547e0d81 | ||
|
|
f0931c447b | ||
|
|
d098b5eb60 | ||
|
|
09111e849d | ||
|
|
49a76dee60 | ||
|
|
637672473e | ||
|
|
e4e1d13b69 | ||
|
|
7736f8c5b4 | ||
|
|
29db69b52f | ||
|
|
37fe51771b | ||
|
|
77ece713dc | ||
|
|
4436cc3a15 | ||
|
|
c875350112 | ||
|
|
d6df2f6bfe | ||
|
|
03e3312218 | ||
|
|
5d7451c3f2 | ||
|
|
551dfa0c7f | ||
|
|
3cc35e8f97 | ||
|
|
01c68ba64e | ||
|
|
61d167d347 | ||
|
|
9393e9f1ca | ||
|
|
c08ef030e9 | ||
|
|
bad13f01f4 | ||
|
|
049e1a2c38 | ||
|
|
a9fb829563 | ||
|
|
e66f731301 | ||
|
|
538ac1d485 | ||
|
|
88fbc503e7 | ||
|
|
1dc3ccbc16 | ||
|
|
df007d8e1b | ||
|
|
0876551795 | ||
|
|
51acb3ac8e | ||
|
|
786af1c79e | ||
|
|
7fba78e44b | ||
|
|
9434cf3216 | ||
|
|
f71ab25407 | ||
|
|
a634813c21 | ||
|
|
17279aaae0 | ||
|
|
4a234e8829 | ||
|
|
805b3c41c8 | ||
|
|
7a154fd847 | ||
|
|
3cdc836e9e | ||
|
|
d232627796 | ||
|
|
579820e606 | ||
|
|
a24a5166f9 | ||
|
|
ad67894244 | ||
|
|
725d4c4ab3 | ||
|
|
6a0310fe05 | ||
|
|
463fd9dd38 | ||
|
|
34fe19abd1 | ||
|
|
3365f082f7 | ||
|
|
13361c57b8 | ||
|
|
2a22b00d53 | ||
|
|
925b52d979 | ||
|
|
468efb63fb | ||
|
|
38aae7eca3 | ||
|
|
44df1134a8 | ||
|
|
243c1673db | ||
|
|
82eab4810c | ||
|
|
e0a59b5729 | ||
|
|
ad3bad85db | ||
|
|
743d85de32 | ||
|
|
8bd32f878f | ||
|
|
eccb52c197 | ||
|
|
603d60d8b8 | ||
|
|
3ef04f4159 | ||
|
|
7888ee7938 | ||
|
|
b2a3cda7b5 | ||
|
|
80c6d29079 | ||
|
|
0b020deaef | ||
|
|
74c8bea756 | ||
|
|
af10d6261f | ||
|
|
a178278576 | ||
|
|
474fea8434 | ||
|
|
d271f7b0f7 | ||
|
|
7e2af515ed | ||
|
|
2d403ff18c | ||
|
|
f08244a990 | ||
|
|
4869e5cf80 | ||
|
|
b887504f9f | ||
|
|
ad43a4f732 | ||
|
|
8699938b61 | ||
|
|
40bd099153 | ||
|
|
750923d5ca | ||
|
|
6d99b30e2d | ||
|
|
a155f16560 | ||
|
|
d4aa045487 | ||
|
|
8080dd9822 | ||
|
|
2eab8d8113 | ||
|
|
b51e56718a | ||
|
|
088d448e10 | ||
|
|
4a89b4bce5 | ||
|
|
b351b4bcd4 | ||
|
|
b2edaf48e4 | ||
|
|
33e02cee82 | ||
|
|
91a8c9eb50 | ||
|
|
50da8a0554 | ||
|
|
42b5978d89 | ||
|
|
bf29fc67b4 | ||
|
|
dbc68d6b56 | ||
|
|
5dabf66e7c | ||
|
|
d6e4478eb6 | ||
|
|
23ef4719ba | ||
|
|
d799a85ab9 | ||
|
|
4ad6bcc636 | ||
|
|
d2473ec7e8 | ||
|
|
28797edc7c | ||
|
|
be1a643071 | ||
|
|
ee6b9778ac | ||
|
|
881c34bcb5 | ||
|
|
c22fc99235 | ||
|
|
0874efe58b | ||
|
|
f01665c998 | ||
|
|
ac23080f6a | ||
|
|
15ee6c4dd1 | ||
|
|
aeaa8f8925 | ||
|
|
59d7e056c4 | ||
|
|
e79afa46b3 | ||
|
|
9714f3d064 | ||
|
|
2c35c6cfd6 | ||
|
|
88f0ce7e51 | ||
|
|
e484164fad | ||
|
|
aa6bce75cd | ||
|
|
512efb595a | ||
|
|
e5dea8e693 | ||
|
|
a704f8003c | ||
|
|
349a25ad58 | ||
|
|
746f46edb3 | ||
|
|
4a29c7a124 | ||
|
|
0e1e412ee9 | ||
|
|
2e84f7c830 | ||
|
|
61a0d68b6a | ||
|
|
ae90029d8e | ||
|
|
ccc83a518c | ||
|
|
7884a5769f | ||
|
|
a663bb330e | ||
|
|
346f0af543 | ||
|
|
49ec1bb7c0 | ||
|
|
fc0cc75dea | ||
|
|
6fc2be5d31 | ||
|
|
db680bf1ba | ||
|
|
1c2b97c0d2 | ||
|
|
59cfbb06a4 | ||
|
|
9291676543 | ||
|
|
0d2ba60728 | ||
|
|
cd1461df6d | ||
|
|
37abfd9ce2 | ||
|
|
c6de3dfd00 | ||
|
|
ae297c780a | ||
|
|
e07a06c8e4 | ||
|
|
1c91f39417 | ||
|
|
13d26be0a2 | ||
|
|
c2740533a8 | ||
|
|
abbc2f25bb | ||
|
|
04b9738a77 | ||
|
|
f5932a301e | ||
|
|
f11e194e6a | ||
|
|
259e47d4d3 | ||
|
|
96c3e00902 | ||
|
|
066414a380 | ||
|
|
b6c7bbb2b7 | ||
|
|
cdce558984 | ||
|
|
a48bb89763 | ||
|
|
e10a8137f4 | ||
|
|
76aeb20268 | ||
|
|
de36ff7d24 | ||
|
|
4b5d5b3a0a | ||
|
|
59f150ebbd | ||
|
|
4e2d39abac | ||
|
|
a35cde5d4a | ||
|
|
bbdf41f334 | ||
|
|
529aedede0 | ||
|
|
55eaa6e751 | ||
|
|
ca59e594b4 | ||
|
|
836fde01b7 | ||
|
|
050ded6b2e | ||
|
|
b9e1abff6b | ||
|
|
f962fcaa99 | ||
|
|
eb35da595f | ||
|
|
26a8b2412f | ||
|
|
601bff4404 | ||
|
|
1fadc9a479 | ||
|
|
69e53569ca | ||
|
|
49a43fb997 | ||
|
|
5145ae399d | ||
|
|
612eb636be | ||
|
|
657af667ef | ||
|
|
9541071c3a | ||
|
|
dfc78b6af5 | ||
|
|
bea7b2d4eb | ||
|
|
ddb3e9e03b | ||
|
|
812965f054 | ||
|
|
006ea2d283 | ||
|
|
08a22ca03f | ||
|
|
7a32917b63 | ||
|
|
54b4417069 | ||
|
|
17d7f54c20 | ||
|
|
2261b5ba53 | ||
|
|
833cb1a24a | ||
|
|
4494b54c15 | ||
|
|
594db5aa0c | ||
|
|
15966df548 | ||
|
|
6914dca9dc | ||
|
|
cbfb5e9531 | ||
|
|
e6c98dd3f3 | ||
|
|
c68fa71d24 | ||
|
|
24b46729e3 | ||
|
|
13336cd02c | ||
|
|
67be9125d8 | ||
|
|
1b0a6dd7f4 | ||
|
|
11053ecfa7 | ||
|
|
0736cb0fb7 | ||
|
|
2acb668101 | ||
|
|
32fb0e9548 | ||
|
|
c69f8e4e65 | ||
|
|
42f91f6ab4 | ||
|
|
f615f21773 | ||
|
|
cad7b53aa4 | ||
|
|
714cae9bd1 | ||
|
|
15036c3755 | ||
|
|
8f5e2f0560 | ||
|
|
a940fcd236 | ||
|
|
b32e2e29d8 | ||
|
|
8f22e2c66e | ||
|
|
8801bb73f4 | ||
|
|
82a215e3bc | ||
|
|
88e5d98c68 | ||
|
|
c7ab57c06a | ||
|
|
f1938b9096 | ||
|
|
5242deea33 | ||
|
|
d06b3cd2a5 | ||
|
|
2a59d1f69c | ||
|
|
358f7ccb98 |
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: [hanxi]
|
||||
custom: ['https://afdian.com/a/imhanxi']
|
||||
37
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
# run unless event type is pull_request
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}
|
||||
- name: Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: hanxi/xiaomusic
|
||||
47
.github/workflows/fmt.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: fmt
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PDM
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
- name: install ruff
|
||||
run: pip install ruff
|
||||
- name: Format code
|
||||
run: pdm fmt && pdm lint --fix
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if [ -n "$(git diff)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
continue-on-error: true
|
||||
|
||||
# Optionally, customize the user name and commit message, and can add an email as well such as Github Actions' email
|
||||
- name: Set up Git and Commit Changes
|
||||
run: |
|
||||
if [ "${{ steps.check_changes.outputs.changed }}" == "true" ]; then
|
||||
git config --local user.name "Formatter [BOT]"
|
||||
git add .
|
||||
git commit -m "Auto-format code 🧹🌟🤖"
|
||||
git push
|
||||
fi
|
||||
39
.github/workflows/release.yml
vendored
@@ -6,41 +6,36 @@ permissions:
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-pypi:
|
||||
name: Build and Release PyPI
|
||||
pypi-publish:
|
||||
name: upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
registry-url: https://registry.npmjs.org/
|
||||
node-version: lts/*
|
||||
|
||||
- run: npx changelogithub
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- uses: pdm-project/setup-pdm@v3
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
run: pdm publish
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
#needs: release-pypi
|
||||
@@ -61,6 +56,6 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: hanxi/xiaomusic:${{ github.ref_name }}
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:latest, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:stable
|
||||
|
||||
11
.gitignore
vendored
@@ -25,7 +25,7 @@ share/python-wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
*_bak/
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
@@ -160,3 +160,12 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
ffmpeg
|
||||
music
|
||||
test.sh
|
||||
conf
|
||||
setting.json
|
||||
.DS_Store
|
||||
cache
|
||||
tmp/
|
||||
|
||||
8
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- push
|
||||
repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v3.27.0
|
||||
804
CHANGELOG.md
Normal file
@@ -0,0 +1,804 @@
|
||||
## v0.3.39 (2024-10-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- 固定的播放列表全部初始化
|
||||
- 生产环境与开发环境接口分离、关于页面增加返回到主页的链接
|
||||
update: 支持https页面未及时更新的问题
|
||||
|
||||
### Fix
|
||||
|
||||
- pure主题 当前设备与远程设备未正确区分的问题 (#234)
|
||||
- static和doc添加basic auth (#231)
|
||||
|
||||
### Refactor
|
||||
|
||||
- 修改默认UI播放提示词 (#233)
|
||||
|
||||
## v0.3.38 (2024-10-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- 播放状态接口返回当前播放列表 (#229)
|
||||
- 新增口令收藏歌曲用来收藏当前播放的歌曲
|
||||
- 默认UI搜索框动态显示 (#228)
|
||||
- 文件转换逻辑延迟到读取文件的时候 see #218
|
||||
- 重写播放组件,现在支持歌词显示了
|
||||
- 使用 /cmdstatus 接口来判断异步任务是否完成
|
||||
- 新增接口 /cmdstatus 用于查询异步任务是否执行完毕
|
||||
- XMusicPlayer播放器主题优化 (#216)
|
||||
- XMusicPlayer播放器主题 (#214)
|
||||
- 新增 yt-dlp cookies 文件参数支持
|
||||
- 新增批量下载歌曲工具
|
||||
- 新增后台网站图标
|
||||
- 加密音乐和图片访问链接 (#200)
|
||||
- 歌曲信息中的图片改为url #190
|
||||
- 新增更新提醒
|
||||
- 定时任务新增刷新播放列表接口
|
||||
- 后台设置名称优化
|
||||
- 新增按钮刷新 tag 信息
|
||||
- 新增 musicinfos 接口用于批量查询歌曲信息
|
||||
- 增加 tags 缓存 (#193)
|
||||
- 使用 opencc 将歌曲名转化为简体 (#192)
|
||||
- 搜索的歌曲存成列表供前端显示,实现额外索引 (#188)
|
||||
- 搜索多个结果,并更新“当前”播放列表 (#185)
|
||||
- musicinfo接口新增musictag参数,用于返回歌曲额外信息
|
||||
- 新增口令【播放列表第几个+列表名】来播放列表里的第几个 #158
|
||||
- 新增定时任务功能 #182
|
||||
- hostname can take protocol,域名支持 https 格式 (#181)
|
||||
|
||||
### Fix
|
||||
|
||||
- xplayer 收藏歌曲、取消收藏 (#230)
|
||||
- 修复型号M01获取对话记录时间戳的问题
|
||||
- 修复型号M01无法获取到对话记录的问题
|
||||
- 使用小爱设备播放时组件异常的问题 (#217)
|
||||
- 修复图片获取失败的问题
|
||||
- 修复 yt-dlp-cookies 报错
|
||||
- 修复自定义口令末尾多余逗号的情况
|
||||
- 修复windows下路径问题
|
||||
- 解决 L05C 无提示音问题 support MiIOService tts (#198)
|
||||
- 解决歌曲信息乱码问题
|
||||
- 修复搜索补全不生效的问题
|
||||
- 修复默认主题没有选中上次播放列表的问题
|
||||
- ffmpeg only output audio (#184)
|
||||
|
||||
### Refactor
|
||||
|
||||
- 新增清理缓存按钮
|
||||
- 优化默认UI的搜索框#226 (#227)
|
||||
- 修复告警
|
||||
- 体验优化,音乐列表缓存 (#222)
|
||||
- 修改为播放选中歌曲
|
||||
- 更新静态文件
|
||||
|
||||
### Perf
|
||||
|
||||
- 对歌曲信息中的图片缩小到300 #190
|
||||
|
||||
## v0.3.37 (2024-09-20)
|
||||
|
||||
### Feat
|
||||
|
||||
- Pure主题更新 设置中心新增主题音乐列表样式选择、夜间模式、其他多项优化 (#180)
|
||||
|
||||
## v0.3.36 (2024-09-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- Pure 主题更新 (#178)
|
||||
- 支持配置获取对话记录间隔时间 #169
|
||||
- 允许在后台设置监听端口
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复开启继续播放时歌曲播放不完整问题 (#177)
|
||||
|
||||
## v0.3.35 (2024-09-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- 允许跨域访问 #172
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复 Pure 主题白屏无法打开的问题 (#176)
|
||||
|
||||
## v0.3.34 (2024-09-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- 新增 pure 主题 vue + elementUI (#172)
|
||||
|
||||
### Fix
|
||||
|
||||
- 主页适配移动端
|
||||
- 修复网页播放点击后没有关闭旧声音的问题 #166
|
||||
- 修复单曲循环的情况下歌曲不在当前播放列表时失效的情况
|
||||
|
||||
### Refactor
|
||||
|
||||
- 优化代码:输入框处理抖动问题,网页播放修改实现方式 see #166
|
||||
|
||||
## v0.3.33 (2024-09-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- 调整页面布局
|
||||
- 支持继续播放 (#171)
|
||||
|
||||
### Fix
|
||||
|
||||
- #168 安全优化: 设置数据接口密码隐藏处理
|
||||
- 修复谷歌统计报错问题
|
||||
|
||||
### Refactor
|
||||
|
||||
- 优化谷歌统计
|
||||
|
||||
## v0.3.32 (2024-09-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- 新增谷歌统计
|
||||
- 增加播放进度 (#160)
|
||||
|
||||
### Fix
|
||||
|
||||
- 优化audio_id查询方式 (#165)
|
||||
- 播放链接接口支持复杂的链接
|
||||
|
||||
## v0.3.31 (2024-09-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- 新增播放上一首歌曲功能 #90
|
||||
- 新增所有歌曲列表
|
||||
- 触屏版显示歌曲名称 (#156)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复插件示例报错 #105
|
||||
- 修复当前播放歌曲没保存的问题 #90
|
||||
|
||||
## v0.3.30 (2024-09-07)
|
||||
|
||||
### Feat
|
||||
|
||||
- 修改设置按钮位置
|
||||
- 新增网页播放接口 #138
|
||||
|
||||
## v0.3.29 (2024-09-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- 设置页面新增接口文档入口
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复网页开启秘密验证无法播歌的问题 #149
|
||||
|
||||
## v0.3.28 (2024-09-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- 新增歌曲收藏功能 #87
|
||||
|
||||
### Fix
|
||||
|
||||
- docker下minetypes无法判断m4a
|
||||
|
||||
### Refactor
|
||||
|
||||
- ffmpeg_location 从配置里读取
|
||||
|
||||
## v0.3.27 (2024-09-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add feature as requested in issue #143
|
||||
|
||||
### Fix
|
||||
|
||||
- 默认下载目录修改
|
||||
|
||||
### Refactor
|
||||
|
||||
- 处理 code review 问题'
|
||||
|
||||
## v0.3.26 (2024-08-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- 删除网关模式
|
||||
|
||||
## v0.3.25 (2024-08-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- 设置页面支持配置 use_music_api 选项
|
||||
|
||||
## v0.3.24 (2024-08-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- #131 修复多设备切换时播放模式显示错误问题
|
||||
|
||||
## v0.3.23 (2024-08-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复部分文件获取不到播放时长问题
|
||||
- 处理安全问题
|
||||
|
||||
## v0.3.22 (2024-08-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- 网关模式支持配置,默认关闭
|
||||
|
||||
### Fix
|
||||
|
||||
- 继续优化延迟问题
|
||||
|
||||
## v0.3.21 (2024-07-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- 尝试加个网关在前面处理静态文件来加速文件获取
|
||||
|
||||
### Fix
|
||||
|
||||
- 使用前置网关处理静态文件来加速,尝试解决延迟的问题
|
||||
- 播放前先立即暂停之前的音乐
|
||||
|
||||
## v0.3.20 (2024-07-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- 尝试修复延迟问题,修复播放停止不了的问题
|
||||
|
||||
## v0.3.19 (2024-07-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- 调整配置,优化获取歌曲时长接口
|
||||
|
||||
## v0.3.18 (2024-07-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- #135 修复获取不到播放时长时只播放3秒的问题
|
||||
|
||||
## v0.3.17 (2024-07-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- 优化日志输出,尝试排查延迟播放的问题
|
||||
|
||||
## v0.3.16 (2024-07-28)
|
||||
|
||||
## v0.3.15 (2024-07-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复自定义口令重复的问题
|
||||
- 修复日志输出问题
|
||||
- 修复退出异常问题
|
||||
|
||||
## v0.3.14 (2024-07-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化播放延迟问题,并新增配置下一首播放的延迟秒数
|
||||
|
||||
## v0.3.13 (2024-07-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- 解决 docker 镜像问题
|
||||
|
||||
## v0.3.12 (2024-07-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化获取文件播放时长接口,尝试解决播放延迟和操作面板卡顿的问题
|
||||
|
||||
## v0.3.11 (2024-07-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add remove mp3 id3 tag function
|
||||
|
||||
### Fix
|
||||
|
||||
- #130 单曲循环的模式下,播放列表的指令不生效
|
||||
|
||||
### Refactor
|
||||
|
||||
- 优化代码
|
||||
|
||||
## v0.3.10 (2024-07-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- 支持软连接的接口直接用os.walk即可
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复软连接目录不能播放的问题
|
||||
- 修复自定义语音口令设置不生效的问题
|
||||
|
||||
## v0.3.9 (2024-07-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- #119 音乐目录支持软连接
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复日志下载报错问题
|
||||
- 兼容旧的setting.json文件中conf_path为空的情况
|
||||
- 修复设置页面可能打不开的问题
|
||||
|
||||
## v0.3.8 (2024-07-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复播放url接口问题
|
||||
|
||||
## v0.3.7 (2024-07-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- 播放链接按钮对应给个默认的链接用于测试
|
||||
- Uvicorn 的日志信息合并到 xiaomusic 日志里显示
|
||||
|
||||
## v0.3.6 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- #126 修复pip安装时主页打不开的问题
|
||||
|
||||
## v0.3.5 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- #116 播放失败自动切下首歌
|
||||
|
||||
## v0.3.4 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- #125 修复本地英文歌曲匹大小写字母配不到的问题
|
||||
|
||||
## v0.3.3 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- 尝试修复播放卡顿问题 see #124
|
||||
|
||||
## v0.3.2 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- #122 pip安装方式下,static目录找不到报错
|
||||
- 版本更新时更新页面缓存
|
||||
|
||||
## v0.3.1 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复主页选择设备不生效的问题 see #120
|
||||
|
||||
## v0.3.0 (2024-07-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- 建议音乐目录和配置目录分开不同目录
|
||||
- 优化后台网络设置,同时支持ipv4和ipv6
|
||||
- 使用fastapi替换flask,解决多线程问题
|
||||
- #106 网页上显示音箱当前状态(播放中or空闲中)以及当前的播放模式
|
||||
- 优化首页加载慢的问题
|
||||
- 优化设置页面布局,方便配置必须项
|
||||
- 优化配置界面,支持配置分组
|
||||
- 支持多设备分开播放 see #65
|
||||
|
||||
### Fix
|
||||
|
||||
- #114 修复部分 mp3 文件长度识别错误
|
||||
- 删除 armv6 的支持
|
||||
- 修复编译问题
|
||||
- 修复音乐路径设置后找不到音乐的问题
|
||||
- 修复启动报错的问题
|
||||
- 修复CI警告问题
|
||||
|
||||
## v0.2.0 (2024-07-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- 触屏版可以不用设置 XIAOMUSIC_USE_MUSIC_API
|
||||
- 升级依赖库
|
||||
- 唤醒口令配置支持配语音词,简化自定义口令配置 see #105
|
||||
|
||||
## v0.1.101 (2024-07-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- #81 修复播放列表时,当前歌曲不在列表没有更换歌曲的问题
|
||||
- #110 修复配置加载问题
|
||||
|
||||
## v0.1.100 (2024-07-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- 日志代码写错
|
||||
|
||||
## v0.1.99 (2024-07-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- #81 修复播放列表没有继续播放上次播放的歌曲,并把随机播放,全部循环,单曲循环状态落地
|
||||
|
||||
## v0.1.98 (2024-07-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复多设备获取不到对话记录的问题 see #65
|
||||
- #93 修复目录深度设置后导致目录下的歌曲无法加到播放列表里的问题
|
||||
|
||||
## v0.1.97 (2024-07-06)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复网页控制台设置页面保存报错
|
||||
|
||||
## v0.1.96 (2024-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- 使用commitizen管理版本号
|
||||
- 页面版本号链接到CHANGELOG页面
|
||||
- 规范版本管理
|
||||
|
||||
## v0.1.95 (2024-07-06)
|
||||
|
||||
## v0.1.94 (2024-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化多设备接口执行效果,尽量做到同时执行
|
||||
|
||||
### Fix
|
||||
|
||||
- 新增参数配置强制打断小爱说话
|
||||
- 修复多设备获取对话记录的问题
|
||||
- 修复windows下路径分隔符被视为转移符导致音箱无法播放音乐的问题
|
||||
- 修复播放链接报错
|
||||
- 修复配置页面默认配置被置空的问题
|
||||
|
||||
## v0.1.93 (2024-07-05)
|
||||
|
||||
### Feat
|
||||
|
||||
- 访问账号密码默认为空
|
||||
- 支持下载的目录与本地音乐目录分开 see #98
|
||||
- 新增m4a文件格式支持
|
||||
- 设置页面支持配置多设备
|
||||
- 默认用空的后台账号和密码
|
||||
- 支持多个设备同时播放 see #65
|
||||
- 新增自定义口令功能 #105
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复设置页面没成功初始化设置问题
|
||||
- 修复镜像缺少文件问题
|
||||
- 尝试解决插件路径问题
|
||||
- 设置页面日志路径写错了
|
||||
- 修复口令导致异常关闭的问题
|
||||
|
||||
## v0.1.92 (2024-07-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- 启动参数新增 --port 配置监听端口
|
||||
- 外网访问端口可独立配置
|
||||
- 优化设置页面,新增更多配置项
|
||||
- 首次保存设置后不需要重启容器
|
||||
|
||||
### Fix
|
||||
|
||||
- 日志文件配置的环境变量写错了
|
||||
|
||||
## v0.1.91 (2024-07-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- 尝试解决触屏版不能播放的问题
|
||||
|
||||
## v0.1.90 (2024-07-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化触屏版播放页面显示歌曲
|
||||
|
||||
## v0.1.89 (2024-07-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- 尝试解决触屏版无法播放的问题
|
||||
|
||||
### Fix
|
||||
|
||||
- 播放歌曲写成固定的了
|
||||
- 播放歌曲时被其他指令打断后没有继续播放
|
||||
|
||||
## v0.1.88 (2024-07-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- 日志里不要输出敏感信息
|
||||
- 优化下载 ffmpeg 脚本,尝试解决 armv7 环境问题
|
||||
- 优化日志输出信息
|
||||
- 尝试解决触屏版无法播放的问题
|
||||
|
||||
### Fix
|
||||
|
||||
- 是否下载中判断错误导致播放无法自动重新开始播放
|
||||
- 升级yt-dlp到2024.07.01
|
||||
- 修复部分型号关机失败的问题
|
||||
|
||||
## v0.1.87 (2024-07-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复XIAOMUSIC_USE_MUSIC_API=true时播放不了的问题
|
||||
|
||||
## v0.1.86 (2024-07-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化 ffmpeg 安装脚本
|
||||
- 新增调试工具用来调试 player_play_music 接口
|
||||
- 升级依赖库 MiService
|
||||
|
||||
### Fix
|
||||
|
||||
- 尝试修复 armv7 的 ffmpeg 问题
|
||||
- 尝试修复关机失败的问题
|
||||
- 修复口令不能播放的问题
|
||||
|
||||
## v0.1.85 (2024-06-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- 版本号链接到github的release页面,方便查看版本更新日志
|
||||
|
||||
### Fix
|
||||
|
||||
- 修复电台删除后没有从电台列表中删除的问题
|
||||
|
||||
## v0.1.84 (2024-06-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- config.json 支持更多配置选项
|
||||
- 新增 XIAOMUSIC_STOP_TTS_MSG 配置关机提示音
|
||||
|
||||
## v0.1.83 (2024-06-30)
|
||||
|
||||
## v0.1.82 (2024-06-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- 优化指令匹配规则
|
||||
|
||||
## v0.1.81 (2024-06-30)
|
||||
|
||||
## v0.1.80 (2024-06-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- #91 修复下载歌曲报错
|
||||
|
||||
## v0.1.79 (2024-06-29)
|
||||
|
||||
## v0.1.77 (2024-06-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- #52 支持配置模糊匹配本地歌曲
|
||||
|
||||
## v0.1.76 (2024-06-28)
|
||||
|
||||
## v0.1.75 (2024-06-28)
|
||||
|
||||
## v0.1.74 (2024-06-28)
|
||||
|
||||
## v0.1.73 (2024-06-28)
|
||||
|
||||
## v0.1.72 (2024-06-28)
|
||||
|
||||
## v0.1.71 (2024-06-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- #83
|
||||
|
||||
## v0.1.70 (2024-06-27)
|
||||
|
||||
## v0.1.69 (2024-06-26)
|
||||
|
||||
## v0.1.67 (2024-06-26)
|
||||
|
||||
## v0.1.66 (2024-06-26)
|
||||
|
||||
## v0.1.65 (2024-06-26)
|
||||
|
||||
## v0.1.64 (2024-06-26)
|
||||
|
||||
## v0.1.62 (2024-06-25)
|
||||
|
||||
## v0.1.61 (2024-06-25)
|
||||
|
||||
## v0.1.60 (2024-06-25)
|
||||
|
||||
## v0.1.58 (2024-06-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- 登陆失败不阻塞启动
|
||||
|
||||
## v0.1.57 (2024-06-24)
|
||||
|
||||
## v0.1.56 (2024-06-24)
|
||||
|
||||
## v0.1.55 (2024-06-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- #47 支持配置基础的BaseAuth登录
|
||||
|
||||
## v0.1.54 (2024-06-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- #76 新增XIAOMUSIC_MUSIC_PATH_DEPTH配置生成播放列表的目录深度,默认10
|
||||
- #74 配置目录可以和下载目录分开配置, 新增XIAOMUSIC_CONF_PATH用来设置配置目录,不配置时使用下载目录
|
||||
|
||||
## v0.1.53 (2024-06-23)
|
||||
|
||||
## v0.1.52 (2024-06-21)
|
||||
|
||||
## v0.1.51 (2024-06-20)
|
||||
|
||||
## v0.1.49 (2024-06-20)
|
||||
|
||||
## v0.1.48 (2024-06-16)
|
||||
|
||||
## v0.1.47 (2024-06-16)
|
||||
|
||||
## v0.1.46 (2024-06-15)
|
||||
|
||||
## v0.1.45 (2024-06-15)
|
||||
|
||||
## v0.1.44 (2024-06-14)
|
||||
|
||||
## v0.1.43 (2024-06-14)
|
||||
|
||||
## v0.1.41 (2024-06-14)
|
||||
|
||||
## v0.1.40 (2024-06-12)
|
||||
|
||||
## v0.1.39 (2024-06-12)
|
||||
|
||||
## v0.1.38 (2024-06-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- #70 下一首歌曲不存在时从播放列表中删除并继续找下一首
|
||||
|
||||
## v0.1.37 (2024-06-04)
|
||||
|
||||
## v0.1.36 (2024-05-30)
|
||||
|
||||
## v0.1.35 (2024-05-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- #67 没配置did时也允许启动 http 服务
|
||||
|
||||
## v0.1.34 (2024-05-19)
|
||||
|
||||
## v0.1.33 (2024-05-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- #50 新增配置页面
|
||||
- #62
|
||||
|
||||
## v0.1.32 (2024-05-17)
|
||||
|
||||
## v0.1.31 (2024-05-16)
|
||||
|
||||
## v0.1.30 (2024-05-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- 控制台显示版本号 #59
|
||||
|
||||
## v0.1.29 (2024-05-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- #57 #55
|
||||
|
||||
## v0.1.28 (2024-05-16)
|
||||
|
||||
## v0.1.27 (2024-05-16)
|
||||
|
||||
## v0.1.26 (2024-05-08)
|
||||
|
||||
## v0.1.25 (2024-05-06)
|
||||
|
||||
## v0.1.24 (2024-04-30)
|
||||
|
||||
## v0.1.23 (2024-04-30)
|
||||
|
||||
## v0.1.22 (2024-04-30)
|
||||
|
||||
## v0.1.21 (2024-04-08)
|
||||
|
||||
## v0.1.20 (2024-04-08)
|
||||
|
||||
## v0.1.19 (2024-04-04)
|
||||
|
||||
## v0.1.18 (2024-02-24)
|
||||
|
||||
## v0.1.16 (2024-02-24)
|
||||
|
||||
## v0.1.15 (2024-02-03)
|
||||
|
||||
## v0.1.14 (2024-02-03)
|
||||
|
||||
## v0.1.13 (2024-02-02)
|
||||
|
||||
## v0.1.12 (2024-01-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- set volume failed
|
||||
|
||||
## v0.1.11 (2024-01-29)
|
||||
|
||||
## v0.1.10 (2024-01-29)
|
||||
|
||||
## v0.1.9 (2024-01-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- arg1 漏修改
|
||||
|
||||
## v0.1.8 (2024-01-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- http server listen host
|
||||
|
||||
## v0.1.7 (2024-01-28)
|
||||
|
||||
## v0.1.6 (2024-01-28)
|
||||
|
||||
## v0.1.5 (2024-01-27)
|
||||
|
||||
## v0.1.4 (2024-01-27)
|
||||
|
||||
### Fix
|
||||
|
||||
- error when play next
|
||||
|
||||
## v0.1.3 (2023-10-15)
|
||||
|
||||
## v0.1.2 (2023-10-15)
|
||||
|
||||
## v0.1.1 (2023-10-14)
|
||||
16
Dockerfile
@@ -1,21 +1,27 @@
|
||||
FROM python:3.10 AS builder
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN pip install -U pdm
|
||||
ENV PDM_CHECK_UPDATE=false
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN python3 -m venv .venv && .venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||
COPY pyproject.toml README.md .
|
||||
COPY xiaomusic/ ./xiaomusic/
|
||||
COPY plugins/ ./plugins/
|
||||
COPY xiaomusic.py .
|
||||
RUN pdm install --prod --no-editable
|
||||
COPY install_dependencies.sh .
|
||||
RUN bash install_dependencies.sh
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/ffmpeg /app/ffmpeg
|
||||
COPY xiaomusic/ ./xiaomusic/
|
||||
COPY plugins/ ./plugins/
|
||||
COPY xiaomusic.py .
|
||||
ENV XDG_CONFIG_HOME=/config
|
||||
ENV XIAOMUSIC_HOSTNAME=192.168.2.5
|
||||
ENV XIAOMUSIC_PORT=8090
|
||||
VOLUME /config
|
||||
VOLUME /app/conf
|
||||
VOLUME /app/music
|
||||
EXPOSE 8090
|
||||
ENV PATH=/app/.venv/bin:$PATH
|
||||
ENTRYPOINT [".venv/bin/python3","xiaomusic.py"]
|
||||
|
||||
328
README.md
@@ -1,60 +1,322 @@
|
||||
# xiaomusic
|
||||
# XiaoMusic: 无限听歌,解放小爱音箱
|
||||
[](https://github.com/hanxi/xiaomusic)
|
||||
[](https://hub.docker.com/r/hanxi/xiaomusic)
|
||||
[](https://hub.docker.com/r/hanxi/xiaomusic)
|
||||
[](https://pypi.org/project/xiaomusic/)
|
||||
[](https://pypi.org/project/xiaomusic/)
|
||||
[](https://pypi.org/project/xiaomusic/)
|
||||
[](https://github.com/hanxi/xiaomusic/releases)
|
||||
|
||||
使用小爱同学播放音乐,音乐使用 yt-dlp 下载。
|
||||
|
||||
## 运行
|
||||
|
||||
使用小爱音箱播放音乐,音乐使用 yt-dlp 下载。
|
||||
|
||||
<https://github.com/hanxi/xiaomusic>
|
||||
|
||||
> [!TIP]
|
||||
> 初次安装遇到问题请查阅 [💬 FAQ问题集合](https://github.com/hanxi/xiaomusic/issues/99) ,一般遇到的问题都已经有解决办法。
|
||||
|
||||
## 👋 最简配置运行
|
||||
|
||||
已经支持在 web 页面配置其他参数,docker 启动命令如下:
|
||||
|
||||
```bash
|
||||
docker run -p 8090:8090 -v /xiaomusic/music:/app/music -v /xiaomusic/conf:/app/conf hanxi/xiaomusic
|
||||
```
|
||||
|
||||
对应的 docker compose 配置如下:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
xiaomusic:
|
||||
image: hanxi/xiaomusic
|
||||
container_name: xiaomusic
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8090:8090
|
||||
volumes:
|
||||
- /xiaomusic/music:/app/music
|
||||
- /xiaomusic/conf:/app/conf
|
||||
```
|
||||
|
||||
其中 conf 目录为配置文件存放目录,music 目录为音乐存放目录,建议分开配置为不同的目录。
|
||||
|
||||
> [!NOTE]
|
||||
> 上面配置的 /xiaomusic/music 和 /xiaomusic/conf 是 docker 主机里的 /xiaomusic 目录下的,可以修改为其他目录。如果报错找不到 /xiaomusic/music 目录,可以先执行 `mkdir -p /xiaomusic/{music,conf}` 命令新建目录。
|
||||
|
||||
docker 和 docker compose 二选一即可,启动成功后,在 web 页面可以配置其他参数,带有 `*` 号的配置是必须要配置的,其他的用不上时不用修改。初次配置时需要在页面上输入小米账号和密码保存后才能获取到设备列表。
|
||||
|
||||
> [!TIP]
|
||||
> 目前安装步骤已经是最简化了,如果还是嫌安装麻烦,可以微信或者 QQ 约我远程安装,我一般周末和晚上才有时间,收个辛苦费 :moneybag: 50 元一次,安装失败不收费。
|
||||
|
||||
### 🔥 修改默认8090端口映射
|
||||
|
||||
如果需要修改 8090 端口为其他端口,比如 5678,需要这样配,3个数字都需要是 5678 。见 <https://github.com/hanxi/xiaomusic/issues/19>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
xiaomusic:
|
||||
image: hanxi/xiaomusic
|
||||
container_name: xiaomusic
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
- /xiaomusic/music:/app/music
|
||||
- /xiaomusic/conf:/app/conf
|
||||
environment:
|
||||
XIAOMUSIC_PORT: 5678
|
||||
```
|
||||
|
||||
如果不是首次修改端口,还需要修改 /xiaomusic/conf/setting.json 文件里的端口(也可以在后台修改监听端口后重启)。
|
||||
|
||||
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> XIAOMUSIC_PORT 也可以在后台配置,对应的是监听端口。
|
||||
|
||||
|
||||
### 🤐 支持语音口令
|
||||
|
||||
- 【播放歌曲】,播放本地的歌曲
|
||||
- 【播放歌曲+歌名】,比如:播放歌曲周杰伦晴天
|
||||
- 【上一首】
|
||||
- 【下一首】
|
||||
- 【单曲循环】
|
||||
- 【全部循环】
|
||||
- 【随机播放】
|
||||
- 【关机】,【停止播放】,两个效果是一样的。
|
||||
- 【刷新列表】,当复制了歌曲进 music 目录后,可以用这个口令刷新歌单。
|
||||
- 【播放列表+列表名】,比如:播放列表其他。
|
||||
- 【加入收藏】,把当前播放的歌曲加入收藏歌单。
|
||||
- 【取消收藏】,把当前播放的歌曲从收藏歌单里移除。
|
||||
- 【播放列表收藏】,这个用于播放收藏歌单。
|
||||
- 【播放本地歌曲+歌名】,这个口令和播放歌曲的区别是本地找不到也不会去下载。
|
||||
- 【播放列表第几个+列表名】,具体见: <https://github.com/hanxi/xiaomusic/issues/158>
|
||||
- 【播放歌曲+关键词】,会搜索关键词作为临时搜索列表播放,比如说【播放歌曲林俊杰】,会播放所有林俊杰的歌。
|
||||
|
||||
> [!TIP]
|
||||
> 隐藏玩法: 对小爱同学说播放歌曲小猪佩奇的故事,会先下载小猪佩奇的故事,然后再播放小猪佩奇的故事。
|
||||
|
||||
更多功能见 [📝 文档汇总](https://github.com/hanxi/xiaomusic/issues/211)
|
||||
|
||||
## 🛠️ pip 方式安装运行
|
||||
|
||||
```shell
|
||||
> pip install -U xiaomusic
|
||||
> xiaomusic --help
|
||||
__ __ _ __ __ _
|
||||
\ \/ / (_) __ _ ___ | \/ | _ _ ___ (_) ___
|
||||
\ / | | / _` | / _ \ | |\/| | | | | | / __| | | / __|
|
||||
/ \ | | | (_| | | (_) | | | | | | |_| | \__ \ | | | (__
|
||||
/_/\_\ |_| \__,_| \___/ |_| |_| \__,_| |___/ |_| \___|
|
||||
XiaoMusic v0.3.37 by: github.com/hanxi
|
||||
|
||||
usage: xiaomusic [-h] [--port PORT] [--hardware HARDWARE] [--account ACCOUNT]
|
||||
[--password PASSWORD] [--cookie COOKIE] [--verbose]
|
||||
[--config CONFIG] [--ffmpeg_location FFMPEG_LOCATION]
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--port PORT 监听端口
|
||||
--hardware HARDWARE 小爱音箱型号
|
||||
--account ACCOUNT xiaomi account
|
||||
--password PASSWORD xiaomi password
|
||||
--cookie COOKIE xiaomi cookie
|
||||
--verbose show info
|
||||
--config CONFIG config file path
|
||||
--ffmpeg_location FFMPEG_LOCATION
|
||||
ffmpeg bin path
|
||||
> xiaomusic --config config.json
|
||||
```
|
||||
|
||||
其中 `config.json` 文件可以参考 `config-example.json` 文件配置。见 <https://github.com/hanxi/xiaomusic/issues/94>
|
||||
|
||||
不修改默认端口 8090 的情况下,只需要执行 `xiaomusic` 即可启动。
|
||||
|
||||
## 🔩 开发环境运行
|
||||
|
||||
- 使用 install_dependencies.sh 下载依赖
|
||||
- 使用 pdm 安装环境
|
||||
- 参考 [xiaogpt](https://github.com/yihong0618/xiaogpt) 设置好环境变量
|
||||
|
||||
```shell
|
||||
export MI_USER="xxxxx"
|
||||
export MI_PASS="xxxx"
|
||||
export MI_DID=00000
|
||||
```
|
||||
|
||||
然后启动即可。默认监听了端口 8090 , 使用其他端口自行修改。
|
||||
- 默认监听了端口 8090 , 使用其他端口自行修改。
|
||||
|
||||
```shell
|
||||
pdm run xiaomusic.py
|
||||
````
|
||||
|
||||
### 支持口令
|
||||
如果是开发前端界面,可以通过 <http://localhost:8090/docs>
|
||||
查看有什么接口。目前的 web 控制台非常简陋,欢迎有兴趣的朋友帮忙实现一个漂亮的前端,需要什么接口可以随时提需求。
|
||||
|
||||
- **播放歌曲**
|
||||
- **播放歌曲**+歌名 比如:播放歌曲周杰伦晴天
|
||||
- 下一首
|
||||
- 单曲循环
|
||||
- 全部循环
|
||||
### 🚦 代码提交规范
|
||||
|
||||
## 已测试设备
|
||||
提交前请执行
|
||||
|
||||
```txt
|
||||
"L07A": ("5-1", "5-5"), # Redmi小爱音箱Play(l7a)
|
||||
````
|
||||
|
||||
## 在 Docker 里使用
|
||||
|
||||
```shell
|
||||
docker run -e MI_USER=<your-xiaomi-account> -e MI_PASS=<your-xiaomi-password> -e MI_DID=<your-xiaomi-speaker-mid> -e MI_HARDWARE='L07A' -e XIAOMUSIC_PROXY=<proxy-for-yt-dlp> -e XIAOMUSIC_HOSTNAME=192.168.2.5 -p 8090:8090 -v ./music:/app/music hanxi/xiaomusic
|
||||
```
|
||||
pdm fmt
|
||||
pdm lint --fix
|
||||
```
|
||||
|
||||
- XIAOMUSIC_PROXY 用于配置代理,默认为空,yt-dlp 工具下载歌曲会用到。
|
||||
- MI_HARDWARE 是小米音箱的型号,默认为'L07A'
|
||||
- 注意端口必须映射为与容器内一致,XIAOMUSIC_HOSTNAME 需要设置为宿主机的 IP 地址,否则小爱无法正常播放。
|
||||
- 可以把 /app/music 目录映射到本地,用于保存下载的歌曲。
|
||||
用于检查代码和格式化代码。
|
||||
|
||||
### 本地编译Docker Image
|
||||
### 本地编译 Docker Image
|
||||
|
||||
```shell
|
||||
docker build -t xiaomusic .
|
||||
```
|
||||
|
||||
## 感谢
|
||||
## 已测试支持的设备
|
||||
|
||||
| 型号 | 名称 |
|
||||
| ---- | ---------------------------------------------------------------------------------------------- |
|
||||
| L06A | [小爱音箱](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l06a) |
|
||||
| L07A | [Redmi小爱音箱 Play](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l7a) |
|
||||
| S12/S12A/MDZ-25-DA | [小米AI音箱](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.s12) |
|
||||
| LX5A | [小爱音箱 万能遥控版](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx5a) |
|
||||
| LX05 | [小爱音箱Play(2019款)](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx05) |
|
||||
| L15A | [小米AI音箱(第二代)](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l15a#/) |
|
||||
| L16A | [Xiaomi Sound](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l16a) |
|
||||
| L17A | [Xiaomi Sound Pro](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l17a) |
|
||||
| LX06 | [小爱音箱Pro](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx06) |
|
||||
| LX01 | [小爱音箱mini](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx01) |
|
||||
| L05B | [小爱音箱Play](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l05b) |
|
||||
| L05C | [小米小爱音箱Play 增强版](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l05c) |
|
||||
| L09A | [小米音箱Art](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l09a) |
|
||||
| LX04 X10A X08A | 已经支持的触屏版 |
|
||||
| M01/XMYX01JY | 小米小爱音箱HD (获取对话记录的接口比较特殊) |
|
||||
|
||||
型号与产品名称对照可以在这里查询 <https://home.miot-spec.com/s/xiaomi.wifispeaker>
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你的设备支持播放,请反馈给我添加到支持列表里,谢谢。
|
||||
> 目前应该所有设备类型都已经支持播放,有问题随时反馈。
|
||||
> 其他触屏版不能播放可以设置【型号兼容模式】选项为 true 试试。见 <https://github.com/hanxi/xiaomusic/issues/30>
|
||||
|
||||
## 🎵 支持音乐格式
|
||||
|
||||
- mp3
|
||||
- flac
|
||||
- wav
|
||||
- ape
|
||||
- ogg
|
||||
- m4a
|
||||
|
||||
> [!NOTE]
|
||||
> 本地音乐会搜索目录下上面格式的文件,下载的歌曲是 mp3 格式的。
|
||||
> 已知 L05B L05C LX06 L16A 不支持 flac 格式。
|
||||
> 如果格式不能播放可以打开【转换为MP3】和【型号兼容模式】选项。具体见 <https://github.com/hanxi/xiaomusic/issues/153#issuecomment-2328168689>
|
||||
|
||||
|
||||
## 💡 简易的控制面板
|
||||
|
||||
浏览器进入 <http://192.168.2.5:8090>
|
||||
|
||||
- ip 是 XIAOMUSIC_HOSTNAME 设置的
|
||||
- 8090 是默认端口
|
||||
- 支持功能
|
||||
- 显示正在播放的歌曲
|
||||
- 模糊搜索本地歌曲
|
||||
- 播放列表
|
||||
- 删除歌曲
|
||||
- 设置页面
|
||||
- 配置网络歌单
|
||||
- 日志文件下载
|
||||
|
||||
采用新的设置页面之后,没有必须在启动前配置的环境变量了,除非是改默认的 8090 端口才需要配置环境变量。
|
||||
|
||||
## 🌏 网络歌单功能
|
||||
|
||||
可以配置一个 json 格式的歌单,支持电台和歌曲,也可以直接用别人分享的链接,同时配备了 m3u 文件格式转换工具,可以很方便的把 m3u 电台文件转换成网络歌单格式的 json 文件,具体用法见 <https://github.com/hanxi/xiaomusic/issues/78>
|
||||
|
||||
> [!NOTE]
|
||||
> 欢迎有想法的朋友们制作更多的歌单转换工具。
|
||||
|
||||
## 🍺 更多其他可选配置
|
||||
|
||||
- XIAOMUSIC_ACTIVE_CMD 环境变量,对应后台的 【允许唤醒的命令】,用于唤醒口令,配置成'play,random_play',在非播放状态下,只有这两个指令(播放歌曲和随机播放)可以触发,触发后,xiaomusic进入playing状态,其他指令则可以正常触发。具体见 <https://github.com/hanxi/xiaomusic/pull/43>
|
||||
- XIAOMUSIC_EXCLUDE_DIRS 配置歌曲目录里需要忽略的目录,对应后台的 【忽略目录】
|
||||
- XIAOMUSIC_MUSIC_PATH_DEPTH 配置歌曲目录搜索深度,对应后台的 【目录深度】,具体见 <https://github.com/hanxi/xiaomusic/issues/76>
|
||||
- XIAOMUSIC_DISABLE_HTTPAUTH 配置成 false 表示开启密码访问web控制台,对应后台的 【关闭密码验证】,具体见 <https://github.com/hanxi/xiaomusic/issues/47>
|
||||
- XIAOMUSIC_HTTPAUTH_USERNAME 配置 web 控制台用户,对应后台的 【控制台账户】
|
||||
- XIAOMUSIC_HTTPAUTH_PASSWORD 配置 web 控制台密码,对应后台的 【控制台密码】
|
||||
- XIAOMUSIC_CONF_PATH 用来存放配置文件的目录,对应后台的 【配置文件目录】,记得把目录映射到主机,默认为 `/app/config` ,具体见 <https://github.com/hanxi/xiaomusic/issues/74>
|
||||
- XIAOMUSIC_CACHE_DIR 用来音乐 tag 缓存,默认为 `/app/cache`,对应后台的 【缓存文件目录】。
|
||||
- XIAOMUSIC_DISABLE_DOWNLOAD 设为 true 时关闭下载功能,对应后台的 【关闭下载功能】,见 <https://github.com/hanxi/xiaomusic/issues/82>
|
||||
- XIAOMUSIC_USE_MUSIC_API 设为 true 时使用 player_play_music 接口播放音乐,对应后台的 【型号兼容模式】,用于兼容不能播放的型号,如果发现需要设置这个选项的时候请告知我加一下设备型号,方便以后不用设置。 见 <https://github.com/hanxi/xiaomusic/issues/30>
|
||||
- XIAOMUSIC_KEYWORDS_PLAY 用来播放歌曲的口令前缀,对应后台的 【播放歌曲口令】,默认是 "播放歌曲,放歌曲" ,可以用英文逗号分割配置多个
|
||||
- XIAOMUSIC_KEYWORDS_STOP 用来关机的口令,对应后台的 【停止口令】,默认是 "关机,暂停,停止" ,可以用英文逗号分割配置多个。
|
||||
- XIAOMUSIC_KEYWORDS_PLAYLOCAL 用来播放本地歌曲的口令前缀,对应后台的 【播放本地歌曲口令】,本地找不到时不会下载歌曲,默认是 "播放本地歌曲,本地播放歌曲" ,可以用英文逗号分割配置多个。
|
||||
- XIAOMUSIC_ENABLE_FUZZY_MATCH 设为 true 时开启模糊匹配(默认),设为 false 时关闭模糊匹配,对应后台的 【开启模糊搜索】,支持模糊匹配歌名和歌单名。 具体见 <https://github.com/hanxi/xiaomusic/issues/52>
|
||||
- XIAOMUSIC_FUZZY_MATCH_CUTOFF 设置模糊搜索匹配的最低相似度阈值(默认0.6,可以配0到1直接的小数),越小越模糊,越大越精准,对应后台的 【模糊匹配阈值】。具体见 <https://github.com/hanxi/xiaomusic/issues/52>
|
||||
- XIAOMUSIC_PUBLIC_PORT 用于设置外网端口,对应后台的 【外网访问端口】,当使用反向代理时可以设置为外网端口,XIAOMUSIC_HOSTNAME 设为外网IP或者域名即可。
|
||||
- XIAOMUSIC_DOWNLOAD_PATH 变量可以配置下载目录,默认为空,表示使用 music 目录为下载目录,对应后台的 【音乐下载目录】。设置这个目录必须是 music 的子目录,否则刷新列表后会找不到歌曲。具体见 <https://github.com/hanxi/xiaomusic/issues/98>
|
||||
- XIAOMUSIC_PROXY 用于配置国内使用 youtube 源下载歌曲时使用的代理,参数格式参考 yt-dlp 文档说明。 见 <https://github.com/hanxi/xiaomusic/issues/2> 和 <https://github.com/hanxi/xiaomusic/issues/11>
|
||||
- MIIO_TTS_CMD 用于部分机型(如:`L05C`)使用 MiIO 支持 tts 能力,默认为空,命令选择见 [MiService-fork 文档](https://github.com/yihong0618/MiService)
|
||||
|
||||
### ⚠️ 安全提醒
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果配置了公网访问 xiaomusic ,请一定要开启密码登陆,并设置复杂的密码。且不要在公共场所的 WiFi 环境下使用,否则可能造成小米账号密码泄露。
|
||||
|
||||
## 🤔 高级篇
|
||||
|
||||
- 自定义口令功能 <https://github.com/hanxi/xiaomusic/issues/105>
|
||||
- [ ] 缺少一篇教程 [如何写自定义插件](https://github.com/hanxi/xiaomusic/issues/105)
|
||||
|
||||
## 📢 讨论区
|
||||
|
||||
- [点击链接加入QQ频道【xiaomusic】](https://pd.qq.com/s/e2jybz0ss)
|
||||
- [点击链接加入群聊【xiaomusic】 604526973](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=13St5PLVcTxYlWTAs_iAawazjtdD1l-a&authKey=dJWEpaT2fDBDpdUUOWj%2FLt6NS1ePBfShDfz7a6seNURi05VvVnAGQzXF%2FM%2F5HgIm&noverify=0&group_code=604526973)
|
||||
- <https://github.com/hanxi/xiaomusic/issues>
|
||||
- [微信群二维码](https://github.com/hanxi/xiaomusic/issues/86)
|
||||
|
||||
## ❤️ 感谢
|
||||
|
||||
- [xiaomi](https://www.mi.com/)
|
||||
- [PDM](https://pdm.fming.dev/latest/)
|
||||
- [xiaogpt](https://github.com/yihong0618/xiaogpt)
|
||||
- [MiService](https://github.com/yihong0618/MiService)
|
||||
- [实现原理](https://github.com/yihong0618/gitblog/issues/258)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [awesome-xiaoai](https://github.com/zzz6519003/awesome-xiaoai)
|
||||
- [微信小程序: XIAO晓音](https://github.com/F-loat/xiaoplayer)
|
||||
- [pure 主题 xiaomusicUI](https://github.com/52fisher/xiaomusicUI)
|
||||
- [移动端的播放器主题](https://github.com/52fisher/XMusicPlayer)
|
||||
- 所有帮忙调试和测试的朋友
|
||||
- 所有反馈问题和建议的朋友
|
||||
|
||||
### 👉 其他教程
|
||||
|
||||
> [!NOTE]
|
||||
> 下面教程可能比较旧,只供参考
|
||||
|
||||
- [NAS部署教程](https://post.m.smzdm.com/p/avpe7n99/)
|
||||
- [群晖部署教程](https://post.m.smzdm.com/p/a7px7dol/)
|
||||
- [QNAS部署教程](https://post.smzdm.com/p/a5xz5x63/)
|
||||
- [视频教程](https://www.bilibili.com/video/BV1ZZpweHEtT/)
|
||||
- [TechHive](https://mp.weixin.qq.com/s/4a41muFtPaFKtHeZYu795w)
|
||||
- [弹个AI](https://mp.weixin.qq.com/s/sIsKxB7Y8b83AhnvaWiMog)
|
||||
- [简单免费!教你用绿联NAS联动小爱音箱,私人音乐库也能语音点播](https://post.m.smzdm.com/p/a8pldgg7/)
|
||||
- [飞牛教程](https://mp.weixin.qq.com/s?t=pages/image_detail&__biz=MzkxODc1NDMwOA==&mid=2247483725&idx=1&sn=2d615f14733b9bf989557fa766b4e1fc)
|
||||
|
||||
## 🚨 免责声明
|
||||
|
||||
本项目仅供学习和研究目的,不得用于任何商业活动。用户在使用本项目时应遵守所在地区的法律法规,对于违法使用所导致的后果,本项目及作者不承担任何责任。
|
||||
本项目可能存在未知的缺陷和风险(包括但不限于设备损坏和账号封禁等),使用者应自行承担使用本项目所产生的所有风险及责任。
|
||||
作者不保证本项目的准确性、完整性、及时性、可靠性,也不承担任何因使用本项目而产生的任何损失或损害责任。
|
||||
使用本项目即表示您已阅读并同意本免责声明的全部内容。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#hanxi/xiaomusic&Date)
|
||||
|
||||
## 赞赏
|
||||
|
||||
- :moneybag: 爱发电 <https://afdian.com/a/imhanxi>
|
||||
- 点个 Star :star:
|
||||
- 谢谢 :heart:
|
||||
- 
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/hanxi/xiaomusic/blob/main/LICENSE) License © 2024 涵曦
|
||||
|
||||
|
||||
84
config-example.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"account": "",
|
||||
"password": "",
|
||||
"mi_did": "",
|
||||
"miio_tts_command": null,
|
||||
"cookie": "",
|
||||
"verbose": false,
|
||||
"music_path": "music",
|
||||
"download_path": "",
|
||||
"conf_path": null,
|
||||
"tag_cache_dir": null,
|
||||
"hostname": "192.168.2.5",
|
||||
"port": 8090,
|
||||
"public_port": 0,
|
||||
"proxy": null,
|
||||
"search_prefix": "bilisearch:",
|
||||
"ffmpeg_location": "./ffmpeg/bin",
|
||||
"active_cmd": "play,set_random_play,playlocal,play_music_list,stop",
|
||||
"exclude_dirs": "@eaDir",
|
||||
"music_path_depth": 10,
|
||||
"disable_httpauth": true,
|
||||
"httpauth_username": "",
|
||||
"httpauth_password": "",
|
||||
"music_list_url": "",
|
||||
"music_list_json": "",
|
||||
"disable_download": false,
|
||||
"key_word_dict": {
|
||||
"播放歌曲": "play",
|
||||
"播放本地歌曲": "playlocal",
|
||||
"关机": "stop",
|
||||
"下一首": "play_next",
|
||||
"单曲循环": "set_play_type_one",
|
||||
"全部循环": "set_play_type_all",
|
||||
"随机播放": "set_random_play",
|
||||
"分钟后关机": "stop_after_minute",
|
||||
"播放列表": "play_music_list",
|
||||
"刷新列表": "gen_music_list",
|
||||
"本地播放歌曲": "playlocal",
|
||||
"放歌曲": "play",
|
||||
"暂停": "stop",
|
||||
"停止": "stop",
|
||||
"停止播放": "stop",
|
||||
"测试自定义口令": "exec#code1(\"hello\")",
|
||||
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
|
||||
},
|
||||
"key_match_order": [
|
||||
"分钟后关机",
|
||||
"播放歌曲",
|
||||
"下一首",
|
||||
"单曲循环",
|
||||
"全部循环",
|
||||
"随机播放",
|
||||
"关机",
|
||||
"刷新列表",
|
||||
"播放列表",
|
||||
"播放本地歌曲",
|
||||
"本地播放歌曲",
|
||||
"放歌曲",
|
||||
"暂停",
|
||||
"停止",
|
||||
"停止播放",
|
||||
"测试自定义口令",
|
||||
"测试链接"
|
||||
],
|
||||
"use_music_api": false,
|
||||
"use_music_audio_id": "1582971365183456177",
|
||||
"use_music_id": "355454500",
|
||||
"log_file": "/tmp/xiaomusic.txt",
|
||||
"fuzzy_match_cutoff": 0.6,
|
||||
"enable_fuzzy_match": true,
|
||||
"stop_tts_msg": "收到,再见",
|
||||
"enable_config_example": true,
|
||||
"keywords_playlocal": "播放本地歌曲,本地播放歌曲",
|
||||
"keywords_play": "播放歌曲,放歌曲",
|
||||
"keywords_stop": "关机,暂停,停止,停止播放",
|
||||
"user_key_word_dict": {
|
||||
"测试自定义口令": "exec#code1(\"hello\")",
|
||||
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
|
||||
},
|
||||
"enable_force_stop": false,
|
||||
"devices": {},
|
||||
"group_list": "",
|
||||
"convert_to_mp3": false
|
||||
}
|
||||
@@ -4,14 +4,47 @@
|
||||
# https://github.com/yt-dlp/yt-dlp#dependencies
|
||||
|
||||
# 判断系统架构
|
||||
arch=$(arch)
|
||||
arch=$(uname -m)
|
||||
|
||||
pkg=ffmpeg-master-latest-linuxarm64-gpl
|
||||
if [[ "${arch}" == "x86_64" ]]; then
|
||||
pkg=ffmpeg-master-latest-linux64-gpl
|
||||
fi
|
||||
# 输出架构信息
|
||||
echo "当前系统架构是:$arch"
|
||||
|
||||
#export ALL_PROXY=http://192.168.2.5:8080
|
||||
wget https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/$pkg.tar.xz
|
||||
tar -xvJf $pkg.tar.xz
|
||||
mv $pkg ffmpeg
|
||||
install_from_github() {
|
||||
pkg=$1
|
||||
wget https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/$pkg.tar.xz
|
||||
tar -xvJf $pkg.tar.xz
|
||||
mkdir -p ffmpeg/bin
|
||||
mv $pkg/bin/ffmpeg ffmpeg/bin/
|
||||
mv $pkg/bin/ffprobe ffmpeg/bin/
|
||||
}
|
||||
|
||||
install_from_ffmpeg() {
|
||||
pkg=$1
|
||||
wget https://johnvansickle.com/ffmpeg/builds/$pkg.tar.xz
|
||||
mkdir -p $pkg
|
||||
tar -xvJf $pkg.tar.xz -C $pkg
|
||||
mkdir -p ffmpeg/bin
|
||||
mv $pkg/*/ffmpeg ffmpeg/bin/
|
||||
mv $pkg/*/ffprobe ffmpeg/bin/
|
||||
}
|
||||
|
||||
# 基于架构执行不同的操作
|
||||
case "$arch" in
|
||||
x86_64)
|
||||
echo "64位 x86 架构"
|
||||
install_from_github ffmpeg-master-latest-linux64-gpl
|
||||
#install_from_ffmpeg ffmpeg-git-amd64-static
|
||||
;;
|
||||
arm64 | aarch64)
|
||||
echo "64位 ARM 架构"
|
||||
install_from_github ffmpeg-master-latest-linuxarm64-gpl
|
||||
#install_from_ffmpeg ffmpeg-git-arm64-static
|
||||
;;
|
||||
armv7l)
|
||||
echo "armv7l 架构"
|
||||
install_from_ffmpeg ffmpeg-git-armhf-static
|
||||
;;
|
||||
*)
|
||||
echo "未知架构 $arch"
|
||||
;;
|
||||
esac
|
||||
|
||||
9
newpatch.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
./update-static-version.py
|
||||
git add xiaomusic/static
|
||||
git commit -m 'build: update static version'
|
||||
|
||||
cz bump --check-consistency --increment patch
|
||||
|
||||
git push -u origin main --tags
|
||||
9
newversion.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
./update-static-version.py
|
||||
git add xiaomusic/static
|
||||
git commit -m 'build: update static version'
|
||||
|
||||
cz bump --check-consistency
|
||||
|
||||
git push -u origin main --tags
|
||||
0
plugins/__init__.py
Normal file
5
plugins/code1.py
Normal file
@@ -0,0 +1,5 @@
|
||||
async def code1(arg1):
|
||||
global log, xiaomusic
|
||||
log.info(f"code1:{arg1}")
|
||||
did = xiaomusic._cur_did
|
||||
await xiaomusic.do_tts(did, "你好,我是自定义的测试口令")
|
||||
10
plugins/httpget.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import requests
|
||||
|
||||
|
||||
def httpget(url):
|
||||
global log
|
||||
|
||||
# 发起请求
|
||||
response = requests.get(url, timeout=5) # 增加超时以避免长时间挂起
|
||||
response.raise_for_status() # 如果响应不是200,引发HTTPError异常
|
||||
log.info(f"httpget url:{url} response:{response.text}")
|
||||
@@ -1,22 +1,87 @@
|
||||
[project]
|
||||
name = "xiaomusic"
|
||||
version = "0.1.4"
|
||||
version = "0.3.39"
|
||||
description = "Play Music with xiaomi AI speaker"
|
||||
authors = [
|
||||
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
||||
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
||||
]
|
||||
dependencies = [
|
||||
"rich>=13.6.0",
|
||||
"requests>=2.31.0",
|
||||
"aiohttp>=3.8.6",
|
||||
"miservice-fork>=2.2.1",
|
||||
"miservice-fork>=2.7.0",
|
||||
"mutagen>=1.47.0",
|
||||
"yt-dlp>=2023.10.13",
|
||||
"yt-dlp>=2024.07.01",
|
||||
"uvicorn>=0.30.1",
|
||||
"fastapi>=0.111.0",
|
||||
"starlette>=0.37.2",
|
||||
"aiofiles>=24.1.0",
|
||||
"ga4mp>=2.0.4",
|
||||
"apscheduler>=3.10.4",
|
||||
"opencc-python-reimplemented==0.1.7",
|
||||
"pillow>=10.4.0",
|
||||
"python-multipart>=0.0.12",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.10,<3.12"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/hanxi/xiaomusic"
|
||||
|
||||
[project.scripts]
|
||||
xiaomusic = "xiaomusic.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[tool.pdm]
|
||||
[tool.pdm.dev-dependencies]
|
||||
lint = [
|
||||
"ruff>=0.4.8",
|
||||
]
|
||||
dev = [
|
||||
"commitizen>=3.27.0",
|
||||
]
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"E", # pycodestyle - Error
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"W", # pycodestyle - Warning
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = [
|
||||
"E501", # line-too-long
|
||||
"W191", # tab-indentation
|
||||
]
|
||||
include = ["**/*.py", "**/*.pyi", "**/pyproject.toml"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.flake8-bugbear]
|
||||
extend-immutable-calls = [
|
||||
"fastapi.Depends",
|
||||
"fastapi.params.Depends",
|
||||
"fastapi.Query",
|
||||
"fastapi.params.Query",
|
||||
"fastapi.File"
|
||||
]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
lint = "ruff check ."
|
||||
fmt = "ruff format ."
|
||||
lintfmt = {composite = ["ruff check --fix .", "ruff format ."]}
|
||||
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
tag_format = "v$version"
|
||||
version_scheme = "pep440"
|
||||
version_provider = "pep621"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
version_files = [
|
||||
"xiaomusic/__init__.py",
|
||||
]
|
||||
|
||||
321
requirements.txt
@@ -1,321 +0,0 @@
|
||||
# This file is @generated by PDM.
|
||||
# Please do not edit it manually.
|
||||
|
||||
aiohttp==3.8.6 \
|
||||
--hash=sha256:0fa375b3d34e71ccccf172cab401cd94a72de7a8cc01847a7b3386204093bb47 \
|
||||
--hash=sha256:14cd52ccf40006c7a6cd34a0f8663734e5363fd981807173faf3a017e202fec9 \
|
||||
--hash=sha256:16d330b3b9db87c3883e565340d292638a878236418b23cc8b9b11a054aaa887 \
|
||||
--hash=sha256:1bed815f3dc3d915c5c1e556c397c8667826fbc1b935d95b0ad680787896a358 \
|
||||
--hash=sha256:1d84166673694841d8953f0a8d0c90e1087739d24632fe86b1a08819168b4566 \
|
||||
--hash=sha256:253bf92b744b3170eb4c4ca2fa58f9c4b87aeb1df42f71d4e78815e6e8b73c9e \
|
||||
--hash=sha256:2817b2f66ca82ee699acd90e05c95e79bbf1dc986abb62b61ec8aaf851e81c93 \
|
||||
--hash=sha256:2d5b785c792802e7b275c420d84f3397668e9d49ab1cb52bd916b3b3ffcf09ad \
|
||||
--hash=sha256:3b2ab182fc28e7a81f6c70bfbd829045d9480063f5ab06f6e601a3eddbbd49a0 \
|
||||
--hash=sha256:3fd194939b1f764d6bb05490987bfe104287bbf51b8d862261ccf66f48fb4096 \
|
||||
--hash=sha256:41bdc2ba359032e36c0e9de5a3bd00d6fb7ea558a6ce6b70acedf0da86458321 \
|
||||
--hash=sha256:41d55fc043954cddbbd82503d9cc3f4814a40bcef30b3569bc7b5e34130718c1 \
|
||||
--hash=sha256:42c89579f82e49db436b69c938ab3e1559e5a4409eb8639eb4143989bc390f2f \
|
||||
--hash=sha256:6c5f938d199a6fdbdc10bbb9447496561c3a9a565b43be564648d81e1102ac22 \
|
||||
--hash=sha256:6e2f9cc8e5328f829f6e1fb74a0a3a939b14e67e80832975e01929e320386b34 \
|
||||
--hash=sha256:76b36b3124f0223903609944a3c8bf28a599b2cc0ce0be60b45211c8e9be97f8 \
|
||||
--hash=sha256:7bc88fc494b1f0311d67f29fee6fd636606f4697e8cc793a2d912ac5b19aa38d \
|
||||
--hash=sha256:8e31e9db1bee8b4f407b77fd2507337a0a80665ad7b6c749d08df595d88f1cf5 \
|
||||
--hash=sha256:93c15c8e48e5e7b89d5cb4613479d144fda8344e2d886cf694fd36db4cc86865 \
|
||||
--hash=sha256:96603a562b546632441926cd1293cfcb5b69f0b4159e6077f7c7dbdfb686af4d \
|
||||
--hash=sha256:9de50a199b7710fa2904be5a4a9b51af587ab24c8e540a7243ab737b45844543 \
|
||||
--hash=sha256:a2ece4af1f3c967a4390c284797ab595a9f1bc1130ef8b01828915a05a6ae684 \
|
||||
--hash=sha256:ad1407db8f2f49329729564f71685557157bfa42b48f4b93e53721a16eb813ed \
|
||||
--hash=sha256:b0cf2a4501bff9330a8a5248b4ce951851e415bdcce9dc158e76cfd55e15085c \
|
||||
--hash=sha256:ccc360e87341ad47c777f5723f68adbb52b37ab450c8bc3ca9ca1f3e849e5fe2 \
|
||||
--hash=sha256:d76e8b13161a202d14c9584590c4df4d068c9567c99506497bdd67eaedf36403 \
|
||||
--hash=sha256:e1d8cb0b56b3587c5c01de3bf2f600f186da7e7b5f7353d1bf26a8ddca57f965 \
|
||||
--hash=sha256:e3f1e3f1a1751bb62b4a1b7f4e435afcdade6c17a4fd9b9d43607cebd242924a \
|
||||
--hash=sha256:e6a00ffcc173e765e200ceefb06399ba09c06db97f401f920513a10c803604ca \
|
||||
--hash=sha256:ec00c3305788e04bf6d29d42e504560e159ccaf0be30c09203b468a6c1ccd3b2 \
|
||||
--hash=sha256:efd2fcf7e7b9d7ab16e6b7d54205beded0a9c8566cb30f09c1abe42b4e22bdcb
|
||||
aiosignal==1.3.1 \
|
||||
--hash=sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc \
|
||||
--hash=sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17
|
||||
async-timeout==4.0.3 \
|
||||
--hash=sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f \
|
||||
--hash=sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028
|
||||
attrs==23.1.0 \
|
||||
--hash=sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04 \
|
||||
--hash=sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015
|
||||
brotli==1.1.0 \
|
||||
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
|
||||
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
|
||||
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
|
||||
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
|
||||
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
|
||||
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
|
||||
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
|
||||
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
|
||||
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
|
||||
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
|
||||
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
|
||||
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
|
||||
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
|
||||
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
|
||||
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
|
||||
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
|
||||
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
|
||||
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
|
||||
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
|
||||
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
|
||||
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
|
||||
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
|
||||
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
|
||||
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
|
||||
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
|
||||
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
|
||||
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
|
||||
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
|
||||
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
|
||||
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
|
||||
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
|
||||
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
|
||||
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
|
||||
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
|
||||
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
|
||||
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
|
||||
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e
|
||||
certifi==2023.7.22 \
|
||||
--hash=sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082 \
|
||||
--hash=sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9
|
||||
charset-normalizer==3.3.0 \
|
||||
--hash=sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786 \
|
||||
--hash=sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e \
|
||||
--hash=sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8 \
|
||||
--hash=sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa \
|
||||
--hash=sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d \
|
||||
--hash=sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382 \
|
||||
--hash=sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678 \
|
||||
--hash=sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b \
|
||||
--hash=sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e \
|
||||
--hash=sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596 \
|
||||
--hash=sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69 \
|
||||
--hash=sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c \
|
||||
--hash=sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459 \
|
||||
--hash=sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7 \
|
||||
--hash=sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908 \
|
||||
--hash=sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a \
|
||||
--hash=sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8 \
|
||||
--hash=sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d \
|
||||
--hash=sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d \
|
||||
--hash=sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34 \
|
||||
--hash=sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6 \
|
||||
--hash=sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e \
|
||||
--hash=sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c \
|
||||
--hash=sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078 \
|
||||
--hash=sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4 \
|
||||
--hash=sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403 \
|
||||
--hash=sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0 \
|
||||
--hash=sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9 \
|
||||
--hash=sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05 \
|
||||
--hash=sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec \
|
||||
--hash=sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56 \
|
||||
--hash=sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e \
|
||||
--hash=sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455 \
|
||||
--hash=sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65 \
|
||||
--hash=sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78 \
|
||||
--hash=sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df \
|
||||
--hash=sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1 \
|
||||
--hash=sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989 \
|
||||
--hash=sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63 \
|
||||
--hash=sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649 \
|
||||
--hash=sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2 \
|
||||
--hash=sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd \
|
||||
--hash=sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5 \
|
||||
--hash=sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe \
|
||||
--hash=sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293 \
|
||||
--hash=sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e \
|
||||
--hash=sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e
|
||||
frozenlist==1.4.0 \
|
||||
--hash=sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01 \
|
||||
--hash=sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251 \
|
||||
--hash=sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9 \
|
||||
--hash=sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b \
|
||||
--hash=sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0 \
|
||||
--hash=sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b \
|
||||
--hash=sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c \
|
||||
--hash=sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467 \
|
||||
--hash=sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1 \
|
||||
--hash=sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300 \
|
||||
--hash=sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea \
|
||||
--hash=sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab \
|
||||
--hash=sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb \
|
||||
--hash=sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8 \
|
||||
--hash=sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62 \
|
||||
--hash=sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326 \
|
||||
--hash=sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c \
|
||||
--hash=sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431 \
|
||||
--hash=sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3 \
|
||||
--hash=sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956 \
|
||||
--hash=sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472 \
|
||||
--hash=sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc \
|
||||
--hash=sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839 \
|
||||
--hash=sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b \
|
||||
--hash=sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f \
|
||||
--hash=sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559 \
|
||||
--hash=sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b \
|
||||
--hash=sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95 \
|
||||
--hash=sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb \
|
||||
--hash=sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963 \
|
||||
--hash=sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f
|
||||
idna==3.4 \
|
||||
--hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
|
||||
--hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
|
||||
markdown-it-py==3.0.0 \
|
||||
--hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \
|
||||
--hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb
|
||||
mdurl==0.1.2 \
|
||||
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
|
||||
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
|
||||
miservice-fork==2.2.1 \
|
||||
--hash=sha256:cf8f3e30b3008de29a69d9a8293cca8428e670ae10a0f791840428ee947fc27e \
|
||||
--hash=sha256:f3f742ccd7cb4a1ed22cfdf95da14f791eff0c6c3a54032dcfe445ae540664df
|
||||
multidict==6.0.4 \
|
||||
--hash=sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9 \
|
||||
--hash=sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8 \
|
||||
--hash=sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03 \
|
||||
--hash=sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710 \
|
||||
--hash=sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569 \
|
||||
--hash=sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636 \
|
||||
--hash=sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49 \
|
||||
--hash=sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93 \
|
||||
--hash=sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0 \
|
||||
--hash=sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4 \
|
||||
--hash=sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc \
|
||||
--hash=sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8 \
|
||||
--hash=sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed \
|
||||
--hash=sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98 \
|
||||
--hash=sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3 \
|
||||
--hash=sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe \
|
||||
--hash=sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988 \
|
||||
--hash=sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c \
|
||||
--hash=sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c \
|
||||
--hash=sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0 \
|
||||
--hash=sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5 \
|
||||
--hash=sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a \
|
||||
--hash=sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b \
|
||||
--hash=sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982 \
|
||||
--hash=sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7 \
|
||||
--hash=sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461 \
|
||||
--hash=sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc \
|
||||
--hash=sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547 \
|
||||
--hash=sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0 \
|
||||
--hash=sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171 \
|
||||
--hash=sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba
|
||||
mutagen==1.47.0 \
|
||||
--hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \
|
||||
--hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
|
||||
pycryptodomex==3.19.0 \
|
||||
--hash=sha256:09c9401dc06fb3d94cb1ec23b4ea067a25d1f4c6b7b118ff5631d0b5daaab3cc \
|
||||
--hash=sha256:0b2f1982c5bc311f0aab8c293524b861b485d76f7c9ab2c3ac9a25b6f7655975 \
|
||||
--hash=sha256:136b284e9246b4ccf4f752d435c80f2c44fc2321c198505de1d43a95a3453b3c \
|
||||
--hash=sha256:2126bc54beccbede6eade00e647106b4f4c21e5201d2b0a73e9e816a01c50905 \
|
||||
--hash=sha256:263de9a96d2fcbc9f5bd3a279f14ea0d5f072adb68ebd324987576ec25da084d \
|
||||
--hash=sha256:50cb18d4dd87571006fd2447ccec85e6cec0136632a550aa29226ba075c80644 \
|
||||
--hash=sha256:5b883e1439ab63af976656446fb4839d566bb096f15fc3c06b5a99cde4927188 \
|
||||
--hash=sha256:5d73e9fa3fe830e7b6b42afc49d8329b07a049a47d12e0ef9225f2fd220f19b2 \
|
||||
--hash=sha256:67c8eb79ab33d0fbcb56842992298ddb56eb6505a72369c20f60bc1d2b6fb002 \
|
||||
--hash=sha256:7cb51096a6a8d400724104db8a7e4f2206041a1f23e58924aa3d8d96bcb48338 \
|
||||
--hash=sha256:800a2b05cfb83654df80266692f7092eeefe2a314fa7901dcefab255934faeec \
|
||||
--hash=sha256:a3866d68e2fc345162b1b9b83ef80686acfe5cec0d134337f3b03950a0a8bf56 \
|
||||
--hash=sha256:a588a1cb7781da9d5e1c84affd98c32aff9c89771eac8eaa659d2760666f7139 \
|
||||
--hash=sha256:a77b79852175064c822b047fee7cf5a1f434f06ad075cc9986aa1c19a0c53eb0 \
|
||||
--hash=sha256:af83a554b3f077564229865c45af0791be008ac6469ef0098152139e6bd4b5b6 \
|
||||
--hash=sha256:b801216c48c0886742abf286a9a6b117e248ca144d8ceec1f931ce2dd0c9cb40 \
|
||||
--hash=sha256:bfb040b5dda1dff1e197d2ef71927bd6b8bfcb9793bc4dfe0bb6df1e691eaacb \
|
||||
--hash=sha256:c01678aee8ac0c1a461cbc38ad496f953f9efcb1fa19f5637cbeba7544792a53 \
|
||||
--hash=sha256:c74eb1f73f788facece7979ce91594dc177e1a9b5d5e3e64697dd58299e5cb4d \
|
||||
--hash=sha256:d4dd3b381ff5a5907a3eb98f5f6d32c64d319a840278ceea1dcfcc65063856f3 \
|
||||
--hash=sha256:edbe083c299835de7e02c8aa0885cb904a75087d35e7bab75ebe5ed336e8c3e2
|
||||
pygments==2.16.1 \
|
||||
--hash=sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692 \
|
||||
--hash=sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29
|
||||
requests==2.31.0 \
|
||||
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
|
||||
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
|
||||
rich==13.6.0 \
|
||||
--hash=sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245 \
|
||||
--hash=sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef
|
||||
urllib3==2.0.6 \
|
||||
--hash=sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2 \
|
||||
--hash=sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564
|
||||
websockets==11.0.3 \
|
||||
--hash=sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd \
|
||||
--hash=sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998 \
|
||||
--hash=sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82 \
|
||||
--hash=sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f \
|
||||
--hash=sha256:1d5023a4b6a5b183dc838808087033ec5df77580485fc533e7dab2567851b0a4 \
|
||||
--hash=sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69 \
|
||||
--hash=sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac \
|
||||
--hash=sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4 \
|
||||
--hash=sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb \
|
||||
--hash=sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54 \
|
||||
--hash=sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf \
|
||||
--hash=sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3 \
|
||||
--hash=sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6 \
|
||||
--hash=sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11 \
|
||||
--hash=sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f \
|
||||
--hash=sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931 \
|
||||
--hash=sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526 \
|
||||
--hash=sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016 \
|
||||
--hash=sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b \
|
||||
--hash=sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288 \
|
||||
--hash=sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97 \
|
||||
--hash=sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d \
|
||||
--hash=sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d \
|
||||
--hash=sha256:bceab846bac555aff6427d060f2fcfff71042dba6f5fca7dc4f75cac815e57ca \
|
||||
--hash=sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9 \
|
||||
--hash=sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b \
|
||||
--hash=sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d \
|
||||
--hash=sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c \
|
||||
--hash=sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5 \
|
||||
--hash=sha256:def07915168ac8f7853812cc593c71185a16216e9e4fa886358a17ed0fd9fcf6 \
|
||||
--hash=sha256:e052b8467dd07d4943936009f46ae5ce7b908ddcac3fda581656b1b19c083d9b \
|
||||
--hash=sha256:e063b1865974611313a3849d43f2c3f5368093691349cf3c7c8f8f75ad7cb280 \
|
||||
--hash=sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c \
|
||||
--hash=sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20 \
|
||||
--hash=sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8 \
|
||||
--hash=sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb \
|
||||
--hash=sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602 \
|
||||
--hash=sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf \
|
||||
--hash=sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564
|
||||
yarl==1.9.2 \
|
||||
--hash=sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571 \
|
||||
--hash=sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7 \
|
||||
--hash=sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191 \
|
||||
--hash=sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea \
|
||||
--hash=sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4 \
|
||||
--hash=sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095 \
|
||||
--hash=sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde \
|
||||
--hash=sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0 \
|
||||
--hash=sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528 \
|
||||
--hash=sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6 \
|
||||
--hash=sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be \
|
||||
--hash=sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a \
|
||||
--hash=sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8 \
|
||||
--hash=sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6 \
|
||||
--hash=sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608 \
|
||||
--hash=sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82 \
|
||||
--hash=sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3 \
|
||||
--hash=sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d \
|
||||
--hash=sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8 \
|
||||
--hash=sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac \
|
||||
--hash=sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8 \
|
||||
--hash=sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0 \
|
||||
--hash=sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb \
|
||||
--hash=sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2 \
|
||||
--hash=sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7 \
|
||||
--hash=sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051 \
|
||||
--hash=sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9 \
|
||||
--hash=sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5 \
|
||||
--hash=sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9 \
|
||||
--hash=sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3 \
|
||||
--hash=sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560
|
||||
yt-dlp==2023.10.13 \
|
||||
--hash=sha256:2b069f22675532eebacdfd6372b1825651a751fef848de9ae6efe6491b2dc38a \
|
||||
--hash=sha256:e026ea1c435ff36eef1215bc4c5bb8c479938b90054997ba99f63a4541fe63b4
|
||||
32
test/test_music_duration.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import math
|
||||
|
||||
from xiaomusic.const import (
|
||||
SUPPORT_MUSIC_TYPE,
|
||||
)
|
||||
from xiaomusic.utils import (
|
||||
get_local_music_duration,
|
||||
traverse_music_directory,
|
||||
)
|
||||
|
||||
|
||||
async def test_one_music(filename):
|
||||
# 获取播放时长
|
||||
duration = await get_local_music_duration(filename)
|
||||
sec = math.ceil(duration)
|
||||
print(f"本地歌曲 : {filename} 的时长 {duration} {sec} 秒")
|
||||
|
||||
|
||||
async def main(directory):
|
||||
# 获取所有歌曲文件
|
||||
local_musics = traverse_music_directory(directory, 10, [], SUPPORT_MUSIC_TYPE)
|
||||
print(local_musics)
|
||||
for _, files in local_musics.items():
|
||||
for file in files:
|
||||
await test_one_music(file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
directory = "./music" # 替换为你的音乐目录路径
|
||||
asyncio.run(main(directory))
|
||||
48
test/test_music_tags.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import traceback
|
||||
|
||||
from xiaomusic.const import (
|
||||
SUPPORT_MUSIC_TYPE,
|
||||
)
|
||||
from xiaomusic.utils import (
|
||||
extract_audio_metadata,
|
||||
traverse_music_directory,
|
||||
)
|
||||
|
||||
# title 标题
|
||||
# artist 艺术家
|
||||
# album 影集
|
||||
# year 年
|
||||
# genre 性
|
||||
# picture 图片
|
||||
# lyrics 歌词
|
||||
|
||||
|
||||
async def test_one_music(filename):
|
||||
# 获取播放时长
|
||||
try:
|
||||
metadata = extract_audio_metadata(filename, "cache/picture_cache")
|
||||
print(metadata)
|
||||
except Exception as e:
|
||||
print(f"歌曲 : {filename} no tag {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
async def main(directory):
|
||||
# 获取所有歌曲文件
|
||||
local_musics = traverse_music_directory(directory, 10, [], SUPPORT_MUSIC_TYPE)
|
||||
for _, files in local_musics.items():
|
||||
for file in files:
|
||||
print(file)
|
||||
# await test_one_music(file)
|
||||
pass
|
||||
|
||||
await test_one_music("./music/4.mp3")
|
||||
# await test_one_music("./music/4 In Love - 一千零一个愿.mp3")
|
||||
# await test_one_music("./music/程响-人间烟火.flac")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
directory = "./music" # 替换为你的音乐目录路径
|
||||
asyncio.run(main(directory))
|
||||
8
test/test_remove_common_prefix.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from xiaomusic.utils import (
|
||||
remove_common_prefix,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
remove_common_prefix(
|
||||
"./tmp/【无损音质】2024年9月酷狗热歌榜TOP100合集(只选热歌最高的)首首王炸,分P合集!"
|
||||
)
|
||||
56
update-static-version.py
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_html_files(directory):
|
||||
"""
|
||||
获取指定目录下所有HTML文件的列表。
|
||||
|
||||
:param directory: 搜索HTML文件的目录。
|
||||
:return: 搜索到的HTML文件的路径列表。
|
||||
"""
|
||||
return list(Path(directory).rglob("*.html"))
|
||||
|
||||
|
||||
def update_html_version(html_files, version):
|
||||
"""
|
||||
更新HTML文件中所有以 ./ 开头的CSS和JS文件引用的版本号。
|
||||
|
||||
:param html_files: 需要更新的HTML文件路径的列表。
|
||||
:param version: 新的版本号字符串。
|
||||
"""
|
||||
pattern = re.compile(r'(\./.*(css|js))\?version=[^"]*"')
|
||||
|
||||
for html_file in html_files:
|
||||
if not html_file.exists():
|
||||
print(f"文件 {html_file} 不存在。")
|
||||
continue
|
||||
|
||||
html_content = html_file.read_text()
|
||||
|
||||
# 更新CSS和JS版本号
|
||||
html_content = pattern.sub(rf'\g<1>?version={version}"', html_content)
|
||||
# html_content = pattern.sub(fr'\g<1>"', html_content)
|
||||
|
||||
# 保存更改到HTML文件
|
||||
html_file.write_text(html_content)
|
||||
|
||||
print(f"文件 {html_file} 已更新为使用新的版本号: {version}")
|
||||
|
||||
|
||||
# 使用案例
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
t = str(int(time.time()))
|
||||
|
||||
# 指定目录
|
||||
html_directory = "xiaomusic/static/default" # 修改为实际的HTML文件目录路径
|
||||
|
||||
# 获取HTML文件列表
|
||||
html_files_to_update = get_html_files(html_directory)
|
||||
|
||||
# 执行更新
|
||||
update_html_version(html_files_to_update, t)
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.3.39"
|
||||
|
||||
82
xiaomusic/analytics.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from ga4mp import GtagMP
|
||||
|
||||
from xiaomusic import __version__
|
||||
|
||||
|
||||
class Analytics:
|
||||
def __init__(self, log):
|
||||
self.gtag = None
|
||||
self.current_date = None
|
||||
self.log = log
|
||||
self.init()
|
||||
|
||||
def init(self):
|
||||
if self.gtag is not None:
|
||||
return
|
||||
|
||||
gtag = GtagMP(
|
||||
api_secret="sVRsf3T9StuWc-ZiWZxDVA",
|
||||
measurement_id="G-Z09NC1K7ZW",
|
||||
client_id="",
|
||||
)
|
||||
gtag.client_id = gtag.random_client_id()
|
||||
gtag.store.set_user_property(name="version", value=__version__)
|
||||
self.gtag = gtag
|
||||
self.log.info("analytics init ok")
|
||||
|
||||
async def run_with_timeout(self, func, *args, **kwargs):
|
||||
try:
|
||||
return await asyncio.wait_for(func(*args, **kwargs), 3)
|
||||
except asyncio.TimeoutError as e:
|
||||
self.log.warning(f"analytics run_with_timeout failed {e}")
|
||||
return None
|
||||
|
||||
async def send_startup_event(self):
|
||||
try:
|
||||
await self.run_with_timeout(self._send_startup_event)
|
||||
except Exception as e:
|
||||
self.log.warning(f"analytics send_startup_event failed {e}")
|
||||
self.init()
|
||||
|
||||
async def _send_startup_event(self):
|
||||
event = self.gtag.create_new_event(name="startup")
|
||||
event.set_event_param(name="version", value=__version__)
|
||||
event_list = [event]
|
||||
self.gtag.send(events=event_list)
|
||||
|
||||
async def send_daily_event(self):
|
||||
try:
|
||||
await self.run_with_timeout(self._send_daily_event)
|
||||
except Exception as e:
|
||||
self.log.warning(f"analytics send_daily_event failed {e}")
|
||||
self.init()
|
||||
|
||||
async def _send_daily_event(self):
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
if self.current_date == current_date:
|
||||
return
|
||||
|
||||
event = self.gtag.create_new_event(name="daily_active_user")
|
||||
event.set_event_param(name="version", value=__version__)
|
||||
event.set_event_param(name="date", value=current_date)
|
||||
event_list = [event]
|
||||
self.gtag.send(events=event_list)
|
||||
self.current_date = current_date
|
||||
|
||||
async def send_play_event(self, name, sec):
|
||||
try:
|
||||
await self.run_with_timeout(self._send_play_event, name, sec)
|
||||
except Exception as e:
|
||||
self.log.warning(f"analytics send_play_event failed {e}")
|
||||
self.init()
|
||||
|
||||
async def _send_play_event(self, name, sec):
|
||||
event = self.gtag.create_new_event(name="play")
|
||||
event.set_event_param(name="version", value=__version__)
|
||||
event.set_event_param(name="music", value=name)
|
||||
event.set_event_param(name="sec", value=sec)
|
||||
event_list = [event]
|
||||
self.gtag.send(events=event_list)
|
||||
139
xiaomusic/cli.py
@@ -1,16 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
|
||||
import uvicorn
|
||||
|
||||
from xiaomusic import __version__
|
||||
from xiaomusic.config import Config
|
||||
from xiaomusic.httpserver import HttpInit
|
||||
from xiaomusic.httpserver import app as HttpApp
|
||||
from xiaomusic.xiaomusic import XiaoMusic
|
||||
|
||||
LOGO = r"""
|
||||
__ __ _ __ __ _
|
||||
\ \/ / (_) __ _ ___ | \/ | _ _ ___ (_) ___
|
||||
\ / | | / _` | / _ \ | |\/| | | | | | / __| | | / __|
|
||||
/ \ | | | (_| | | (_) | | | | | | |_| | \__ \ | | | (__
|
||||
/_/\_\ |_| \__,_| \___/ |_| |_| \__,_| |___/ |_| \___|
|
||||
{}
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
dest="port",
|
||||
help="监听端口",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hardware",
|
||||
dest="hardware",
|
||||
help="小爱 hardware",
|
||||
help="小爱音箱型号",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--account",
|
||||
@@ -27,20 +49,6 @@ def main():
|
||||
dest="cookie",
|
||||
help="xiaomi cookie",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_command",
|
||||
dest="use_command",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="use command to tts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mute_xiaoai",
|
||||
dest="mute_xiaoai",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="try to mute xiaoai answer",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
dest="verbose",
|
||||
@@ -53,13 +61,106 @@ def main():
|
||||
dest="config",
|
||||
help="config file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ffmpeg_location",
|
||||
dest="ffmpeg_location",
|
||||
help="ffmpeg bin path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable_config_example",
|
||||
dest="enable_config_example",
|
||||
help="是否输出示例配置文件",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
print(LOGO.format(f"XiaoMusic v{__version__} by: github.com/hanxi"))
|
||||
|
||||
options = parser.parse_args()
|
||||
config = Config.from_options(options)
|
||||
|
||||
xiaomusic = XiaoMusic(config)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(xiaomusic.run_forever())
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": f"%(asctime)s [{__version__}] [%(levelname)s] %(message)s",
|
||||
"datefmt": "[%X]",
|
||||
"use_colors": False,
|
||||
},
|
||||
"access": {
|
||||
"format": f"%(asctime)s [{__version__}] [%(levelname)s] %(message)s",
|
||||
"datefmt": "[%X]",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"formatter": "default",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stderr",
|
||||
},
|
||||
"access": {
|
||||
"formatter": "access",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
"file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "access",
|
||||
"filename": config.log_file,
|
||||
"maxBytes": 10 * 1024 * 1024,
|
||||
"backupCount": 1,
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {
|
||||
"handlers": [
|
||||
"default",
|
||||
"file",
|
||||
],
|
||||
"level": "INFO",
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": "INFO",
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": [
|
||||
"access",
|
||||
"file",
|
||||
],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
filename = config.getsettingfile()
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
data = json.loads(f.read())
|
||||
config.update_config(data)
|
||||
except Exception as e:
|
||||
print(f"Execption {e}")
|
||||
|
||||
def run_server(port):
|
||||
xiaomusic = XiaoMusic(config)
|
||||
HttpInit(xiaomusic)
|
||||
uvicorn.run(
|
||||
HttpApp,
|
||||
host=["0.0.0.0", "::"],
|
||||
port=port,
|
||||
log_config=LOGGING_CONFIG,
|
||||
)
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("主进程收到退出信号,准备退出...")
|
||||
os._exit(0) # 退出主进程
|
||||
|
||||
# 捕获主进程的退出信号
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
port = int(config.port)
|
||||
run_server(port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,78 +3,198 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import get_type_hints
|
||||
|
||||
from xiaomusic.utils import validate_proxy
|
||||
|
||||
LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}×tamp={timestamp}&limit=2"
|
||||
COOKIE_TEMPLATE = "deviceId={device_id}; serviceToken={service_token}; userId={user_id}"
|
||||
|
||||
HARDWARE_COMMAND_DICT = {
|
||||
# hardware: (tts_command, wakeup_command)
|
||||
"LX06": ("5-1", "5-5"),
|
||||
"L05B": ("5-3", "5-4"),
|
||||
"S12A": ("5-1", "5-5"),
|
||||
"LX01": ("5-1", "5-5"),
|
||||
"L06A": ("5-1", "5-5"),
|
||||
"LX04": ("5-1", "5-4"),
|
||||
"L05C": ("5-3", "5-4"),
|
||||
"L17A": ("7-3", "7-4"),
|
||||
"X08E": ("7-3", "7-4"),
|
||||
"LX05A": ("5-1", "5-5"), # 小爱红外版
|
||||
"LX5A": ("5-1", "5-5"), # 小爱红外版
|
||||
"L07A": ("5-1", "5-5"), # Redmi小爱音箱Play(l7a)
|
||||
"L15A": ("7-3", "7-4"),
|
||||
"X6A": ("7-3", "7-4"), # 小米智能家庭屏6
|
||||
"X10A": ("7-3", "7-4"), # 小米智能家庭屏10
|
||||
# add more here
|
||||
# 默认口令
|
||||
def default_key_word_dict():
|
||||
return {
|
||||
"播放歌曲": "play",
|
||||
"播放本地歌曲": "playlocal",
|
||||
"关机": "stop",
|
||||
"下一首": "play_next",
|
||||
"上一首": "play_prev",
|
||||
"单曲循环": "set_play_type_one",
|
||||
"全部循环": "set_play_type_all",
|
||||
"随机播放": "set_random_play",
|
||||
"分钟后关机": "stop_after_minute",
|
||||
"播放列表": "play_music_list",
|
||||
"刷新列表": "gen_music_list",
|
||||
"加入收藏": "add_to_favorites",
|
||||
"收藏歌曲": "add_to_favorites",
|
||||
"取消收藏": "del_from_favorites",
|
||||
"播放列表第": "play_music_list_index",
|
||||
}
|
||||
|
||||
|
||||
def default_user_key_word_dict():
|
||||
return {
|
||||
"测试自定义口令": 'exec#code1("hello")',
|
||||
"测试链接": 'exec#httpget("https://github.com/hanxi/xiaomusic")',
|
||||
}
|
||||
|
||||
|
||||
# 命令参数在前面
|
||||
KEY_WORD_ARG_BEFORE_DICT = {
|
||||
"分钟后关机": True,
|
||||
}
|
||||
|
||||
DEFAULT_COMMAND = ("5-1", "5-5")
|
||||
|
||||
KEY_WORD_DICT = {
|
||||
"播放歌曲": "play",
|
||||
"放歌曲": "play",
|
||||
"下一首": "play_next",
|
||||
"单曲循环": "set_play_type_one",
|
||||
"全部循环": "set_play_type_all",
|
||||
"关机": "stop",
|
||||
"停止播放": "stop",
|
||||
}
|
||||
# 口令匹配优先级
|
||||
def default_key_match_order():
|
||||
return [
|
||||
"分钟后关机",
|
||||
"播放歌曲",
|
||||
"下一首",
|
||||
"上一首",
|
||||
"单曲循环",
|
||||
"全部循环",
|
||||
"随机播放",
|
||||
"关机",
|
||||
"刷新列表",
|
||||
"播放列表第",
|
||||
"播放列表",
|
||||
"加入收藏",
|
||||
"收藏歌曲",
|
||||
"取消收藏",
|
||||
]
|
||||
|
||||
SUPPORT_MUSIC_TYPE = [
|
||||
"mp3",
|
||||
"flac",
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
did: str = ""
|
||||
device_id: str = ""
|
||||
hardware: str = ""
|
||||
name: str = ""
|
||||
play_type: int = ""
|
||||
cur_music: str = ""
|
||||
cur_playlist: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
hardware: str = os.getenv("MI_HARDWARE", "L07A")
|
||||
account: str = os.getenv("MI_USER", "")
|
||||
password: str = os.getenv("MI_PASS", "")
|
||||
mi_did: str = os.getenv("MI_DID", "")
|
||||
mute_xiaoai: bool = True
|
||||
mi_did: str = os.getenv("MI_DID", "") # 逗号分割支持多设备
|
||||
miio_tts_command: str = os.getenv("MIIO_TTS_CMD", "")
|
||||
cookie: str = ""
|
||||
use_command: bool = True
|
||||
verbose: bool = False
|
||||
music_path: str = os.getenv("XIAOMUSIC_MUSIC_PATH", "music")
|
||||
verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true"
|
||||
music_path: str = os.getenv(
|
||||
"XIAOMUSIC_MUSIC_PATH", "music"
|
||||
) # 只能是music目录下的子目录
|
||||
download_path: str = os.getenv("XIAOMUSIC_DOWNLOAD_PATH", "music/download")
|
||||
conf_path: str = os.getenv("XIAOMUSIC_CONF_PATH", "conf")
|
||||
cache_dir: str = os.getenv("XIAOMUSIC_CACHE_DIR", "cache")
|
||||
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "192.168.2.5")
|
||||
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090"))
|
||||
proxy: str | None = os.getenv("XIAOMUSIC_PROXY", None)
|
||||
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
|
||||
public_port: int = int(os.getenv("XIAOMUSIC_PUBLIC_PORT", 0)) # 歌曲访问端口
|
||||
proxy: str = os.getenv("XIAOMUSIC_PROXY", None)
|
||||
search_prefix: str = os.getenv(
|
||||
"XIAOMUSIC_SEARCH", "bilisearch:"
|
||||
) # "bilisearch:" or "ytsearch:"
|
||||
ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin")
|
||||
active_cmd: str = os.getenv(
|
||||
"XIAOMUSIC_ACTIVE_CMD",
|
||||
"play,set_random_play,playlocal,play_music_list,play_music_list_index,stop_after_minute,stop",
|
||||
)
|
||||
exclude_dirs: str = os.getenv("XIAOMUSIC_EXCLUDE_DIRS", "@eaDir,tmp")
|
||||
music_path_depth: int = int(os.getenv("XIAOMUSIC_MUSIC_PATH_DEPTH", "10"))
|
||||
disable_httpauth: bool = (
|
||||
os.getenv("XIAOMUSIC_DISABLE_HTTPAUTH", "true").lower() == "true"
|
||||
)
|
||||
httpauth_username: str = os.getenv("XIAOMUSIC_HTTPAUTH_USERNAME", "")
|
||||
httpauth_password: str = os.getenv("XIAOMUSIC_HTTPAUTH_PASSWORD", "")
|
||||
music_list_url: str = os.getenv("XIAOMUSIC_MUSIC_LIST_URL", "")
|
||||
music_list_json: str = os.getenv("XIAOMUSIC_MUSIC_LIST_JSON", "")
|
||||
custom_play_list_json: str = os.getenv("XIAOMUSIC_CUSTOM_PLAY_LIST_JSON", "")
|
||||
disable_download: bool = (
|
||||
os.getenv("XIAOMUSIC_DISABLE_DOWNLOAD", "false").lower() == "true"
|
||||
)
|
||||
key_word_dict: dict[str, str] = field(default_factory=default_key_word_dict)
|
||||
key_match_order: list[str] = field(default_factory=default_key_match_order)
|
||||
use_music_api: bool = (
|
||||
os.getenv("XIAOMUSIC_USE_MUSIC_API", "false").lower() == "true"
|
||||
)
|
||||
use_music_audio_id: str = os.getenv(
|
||||
"XIAOMUSIC_USE_MUSIC_AUDIO_ID", "1582971365183456177"
|
||||
)
|
||||
use_music_id: str = os.getenv("XIAOMUSIC_USE_MUSIC_ID", "355454500")
|
||||
log_file: str = os.getenv("XIAOMUSIC_LOG_FILE", "/tmp/xiaomusic.txt")
|
||||
# 模糊搜索匹配的最低相似度阈值
|
||||
fuzzy_match_cutoff: float = float(os.getenv("XIAOMUSIC_FUZZY_MATCH_CUTOFF", "0.6"))
|
||||
# 开启模糊搜索
|
||||
enable_fuzzy_match: bool = (
|
||||
os.getenv("XIAOMUSIC_ENABLE_FUZZY_MATCH", "true").lower() == "true"
|
||||
)
|
||||
stop_tts_msg: str = os.getenv("XIAOMUSIC_STOP_TTS_MSG", "收到,再见")
|
||||
enable_config_example: bool = False
|
||||
|
||||
keywords_playlocal: str = os.getenv(
|
||||
"XIAOMUSIC_KEYWORDS_PLAYLOCAL", "播放本地歌曲,本地播放歌曲"
|
||||
)
|
||||
keywords_play: str = os.getenv("XIAOMUSIC_KEYWORDS_PLAY", "播放歌曲,放歌曲")
|
||||
keywords_stop: str = os.getenv("XIAOMUSIC_KEYWORDS_STOP", "关机,暂停,停止,停止播放")
|
||||
user_key_word_dict: dict[str, str] = field(
|
||||
default_factory=default_user_key_word_dict
|
||||
)
|
||||
enable_force_stop: bool = (
|
||||
os.getenv("XIAOMUSIC_ENABLE_FORCE_STOP", "false").lower() == "true"
|
||||
)
|
||||
devices: dict[str, Device] = field(default_factory=dict)
|
||||
group_list: str = os.getenv(
|
||||
"XIAOMUSIC_GROUP_LIST", ""
|
||||
) # did1:group_name,did2:group_name
|
||||
remove_id3tag: bool = (
|
||||
os.getenv("XIAOMUSIC_REMOVE_ID3TAG", "false").lower() == "true"
|
||||
)
|
||||
convert_to_mp3: bool = os.getenv("CONVERT_TO_MP3", "false").lower() == "true"
|
||||
delay_sec: int = int(os.getenv("XIAOMUSIC_DELAY_SEC", 3)) # 下一首歌延迟播放秒数
|
||||
continue_play: bool = (
|
||||
os.getenv("XIAOMUSIC_CONTINUE_PLAY", "false").lower() == "true"
|
||||
)
|
||||
pull_ask_sec: int = int(os.getenv("XIAOMUSIC_PULL_ASK_SEC", "1"))
|
||||
crontab_json: str = os.getenv("XIAOMUSIC_CRONTAB_JSON", "") # 定时任务
|
||||
enable_yt_dlp_cookies: bool = (
|
||||
os.getenv("XIAOMUSIC_ENABLE_YT_DLP_COOKIES", "false").lower() == "true"
|
||||
)
|
||||
get_ask_by_mina: bool = (
|
||||
os.getenv("XIAOMUSIC_GET_ASK_BY_MINA", "false").lower() == "true"
|
||||
)
|
||||
|
||||
def append_keyword(self, keys, action):
|
||||
for key in keys.split(","):
|
||||
if key:
|
||||
self.key_word_dict[key] = action
|
||||
if key not in self.key_match_order:
|
||||
self.key_match_order.append(key)
|
||||
|
||||
def append_user_keyword(self):
|
||||
for k, v in self.user_key_word_dict.items():
|
||||
self.key_word_dict[k] = v
|
||||
if k not in self.key_match_order:
|
||||
self.key_match_order.append(k)
|
||||
|
||||
def init_keyword(self):
|
||||
self.key_match_order = default_key_match_order()
|
||||
self.key_word_dict = default_key_word_dict()
|
||||
self.append_keyword(self.keywords_playlocal, "playlocal")
|
||||
self.append_keyword(self.keywords_play, "play")
|
||||
self.append_keyword(self.keywords_stop, "stop")
|
||||
self.append_user_keyword()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.proxy:
|
||||
validate_proxy(self.proxy)
|
||||
|
||||
@property
|
||||
def tts_command(self) -> str:
|
||||
return HARDWARE_COMMAND_DICT.get(self.hardware, DEFAULT_COMMAND)[0]
|
||||
|
||||
@property
|
||||
def wakeup_command(self) -> str:
|
||||
return HARDWARE_COMMAND_DICT.get(self.hardware, DEFAULT_COMMAND)[1]
|
||||
self.init_keyword()
|
||||
# 保存配置到 config-example.json 文件
|
||||
if self.enable_config_example:
|
||||
with open("config-example.json", "w") as f:
|
||||
data = asdict(self)
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_options(cls, options: argparse.Namespace) -> Config:
|
||||
@@ -86,12 +206,75 @@ class Config:
|
||||
config[key] = value
|
||||
return cls(**config)
|
||||
|
||||
@classmethod
|
||||
def convert_value(cls, k, v, type_hints):
|
||||
if v is not None and k in type_hints:
|
||||
expected_type = type_hints[k]
|
||||
try:
|
||||
if expected_type is bool:
|
||||
converted_value = False
|
||||
if str(v).lower() == "true":
|
||||
converted_value = True
|
||||
elif expected_type == dict[str, Device]:
|
||||
converted_value = {}
|
||||
for kk, vv in v.items():
|
||||
converted_value[kk] = Device(**vv)
|
||||
else:
|
||||
converted_value = expected_type(v)
|
||||
return converted_value
|
||||
except (ValueError, TypeError) as e:
|
||||
print(f"Error converting {k}:{v} to {expected_type}: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def read_from_file(cls, config_path: str) -> dict:
|
||||
result = {}
|
||||
with open(config_path, "rb") as f:
|
||||
config = json.load(f)
|
||||
for key, value in config.items():
|
||||
if value is not None and key in cls.__dataclass_fields__:
|
||||
result[key] = value
|
||||
data = json.load(f)
|
||||
type_hints = get_type_hints(cls)
|
||||
|
||||
for k, v in data.items():
|
||||
converted_value = cls.convert_value(k, v, type_hints)
|
||||
if converted_value is not None:
|
||||
result[k] = converted_value
|
||||
return result
|
||||
|
||||
def update_config(self, data):
|
||||
type_hints = get_type_hints(self, globals(), locals())
|
||||
|
||||
for k, v in data.items():
|
||||
converted_value = self.convert_value(k, v, type_hints)
|
||||
if converted_value is not None:
|
||||
setattr(self, k, converted_value)
|
||||
self.init_keyword()
|
||||
|
||||
# 获取设置文件
|
||||
def getsettingfile(self):
|
||||
# 兼容旧配置空的情况
|
||||
if not self.conf_path:
|
||||
self.conf_path = "conf"
|
||||
if not os.path.exists(self.conf_path):
|
||||
os.makedirs(self.conf_path)
|
||||
filename = os.path.join(self.conf_path, "setting.json")
|
||||
return filename
|
||||
|
||||
@property
|
||||
def tag_cache_path(self):
|
||||
if not os.path.exists(self.cache_dir):
|
||||
os.makedirs(self.cache_dir)
|
||||
filename = os.path.join(self.cache_dir, "tag_cache.json")
|
||||
return filename
|
||||
|
||||
@property
|
||||
def picture_cache_path(self):
|
||||
cache_path = os.path.join(self.cache_dir, "picture_cache")
|
||||
if not os.path.exists(cache_path):
|
||||
os.makedirs(cache_path)
|
||||
return cache_path
|
||||
|
||||
@property
|
||||
def yt_dlp_cookies_path(self):
|
||||
if not os.path.exists(self.conf_path):
|
||||
os.makedirs(self.conf_path)
|
||||
cookies_path = os.path.join(self.conf_path, "yt-dlp-cookie.txt")
|
||||
return cookies_path
|
||||
|
||||
26
xiaomusic/const.py
Normal file
@@ -0,0 +1,26 @@
|
||||
SUPPORT_MUSIC_TYPE = [
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".ape",
|
||||
".ogg",
|
||||
".m4a",
|
||||
]
|
||||
|
||||
LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}×tamp={timestamp}&limit=2"
|
||||
COOKIE_TEMPLATE = "deviceId={device_id}; serviceToken={service_token}; userId={user_id}"
|
||||
|
||||
PLAY_TYPE_ONE = 0 # 单曲循环
|
||||
PLAY_TYPE_ALL = 1 # 全部循环
|
||||
PLAY_TYPE_RND = 2 # 随机播放
|
||||
|
||||
PLAY_TYPE_TTS = {
|
||||
PLAY_TYPE_ONE: "已经设置为单曲循环",
|
||||
PLAY_TYPE_ALL: "已经设置为全部循环",
|
||||
PLAY_TYPE_RND: "已经设置为随机播放",
|
||||
}
|
||||
|
||||
# 需要采用 mina 获取对话记录的设备型号
|
||||
GET_ASK_BY_MINA = {
|
||||
"M01",
|
||||
}
|
||||
98
xiaomusic/crontab.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import json
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
|
||||
class Crontab:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
|
||||
def start(self):
|
||||
self.scheduler.start()
|
||||
|
||||
def add_job(self, expression, job):
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(expression)
|
||||
self.scheduler.add_job(job, trigger)
|
||||
except ValueError as e:
|
||||
self.log.error(f"Invalid crontab expression {e}")
|
||||
except Exception as e:
|
||||
self.log.exception(f"Execption {e}")
|
||||
|
||||
# 添加关机任务
|
||||
def add_job_stop(self, expression, xiaomusic, did, **kwargs):
|
||||
async def job():
|
||||
await xiaomusic.stop(did, "notts")
|
||||
|
||||
self.add_job(expression, job)
|
||||
|
||||
# 添加播放任务
|
||||
def add_job_play(self, expression, xiaomusic, did, arg1, **kwargs):
|
||||
async def job():
|
||||
await xiaomusic.play(did, arg1)
|
||||
|
||||
self.add_job(expression, job)
|
||||
|
||||
# 添加播放列表任务
|
||||
def add_job_play_music_list(self, expression, xiaomusic, did, arg1, **kwargs):
|
||||
async def job():
|
||||
await xiaomusic.play_music_list(did, arg1)
|
||||
|
||||
self.add_job(expression, job)
|
||||
|
||||
# 添加语音播放任务
|
||||
def add_job_tts(self, expression, xiaomusic, did, arg1, **kwargs):
|
||||
async def job():
|
||||
xiaomusic.do_tts(did, arg1)
|
||||
|
||||
self.add_job(expression, job)
|
||||
|
||||
# 刷新播放列表任务
|
||||
def add_job_refresh_music_list(self, expression, xiaomusic, **kwargs):
|
||||
async def job():
|
||||
await xiaomusic.gen_music_list()
|
||||
|
||||
self.add_job(expression, job)
|
||||
|
||||
def add_job_cron(self, xiaomusic, cron):
|
||||
expression = cron["expression"] # cron 计划格式
|
||||
name = cron["name"] # stop, play, play_music_list, tts
|
||||
did = cron["did"]
|
||||
arg1 = cron.get("arg1", "")
|
||||
jobname = f"add_job_{name}"
|
||||
func = getattr(self, jobname, None)
|
||||
if callable(func):
|
||||
func(expression, xiaomusic, did=did, arg1=arg1)
|
||||
self.log.info(
|
||||
f"crontab add_job_cron ok. did:{did}, name:{name}, arg1:{arg1}"
|
||||
)
|
||||
else:
|
||||
self.log.error(
|
||||
f"'{self.__class__.__name__}' object has no attribute '{jobname}'"
|
||||
)
|
||||
|
||||
# 清空任务
|
||||
def clear_jobs(self):
|
||||
for job in self.scheduler.get_jobs():
|
||||
try:
|
||||
job.remove()
|
||||
except Exception as e:
|
||||
self.log.exception(f"Execption {e}")
|
||||
|
||||
# 重新加载计划任务
|
||||
def reload_config(self, xiaomusic):
|
||||
self.clear_jobs()
|
||||
|
||||
crontab_json = xiaomusic.config.crontab_json
|
||||
if not crontab_json:
|
||||
return
|
||||
|
||||
try:
|
||||
cron_list = json.loads(crontab_json)
|
||||
for cron in cron_list:
|
||||
self.add_job_cron(xiaomusic, cron)
|
||||
self.log.info("crontab reload_config ok")
|
||||
except Exception as e:
|
||||
self.log.exception(f"Execption {e}")
|
||||
631
xiaomusic/httpserver.py
Normal file
@@ -0,0 +1,631 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
from typing import Annotated
|
||||
|
||||
import aiofiles
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
File,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import FileResponse, Response
|
||||
|
||||
from xiaomusic import __version__
|
||||
from xiaomusic.utils import (
|
||||
convert_file_to_mp3,
|
||||
deepcopy_data_no_sensitive_info,
|
||||
download_one_music,
|
||||
download_playlist,
|
||||
downloadfile,
|
||||
get_latest_version,
|
||||
is_mp3,
|
||||
remove_common_prefix,
|
||||
remove_id3_tags,
|
||||
try_add_access_control_param,
|
||||
)
|
||||
|
||||
xiaomusic = None
|
||||
config = None
|
||||
log = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(app):
|
||||
if xiaomusic is not None:
|
||||
asyncio.create_task(xiaomusic.run_forever())
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
log.exception(f"Execption {e}")
|
||||
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
def verification(
|
||||
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
|
||||
):
|
||||
current_username_bytes = credentials.username.encode("utf8")
|
||||
correct_username_bytes = config.httpauth_username.encode("utf8")
|
||||
is_correct_username = secrets.compare_digest(
|
||||
current_username_bytes, correct_username_bytes
|
||||
)
|
||||
current_password_bytes = credentials.password.encode("utf8")
|
||||
correct_password_bytes = config.httpauth_password.encode("utf8")
|
||||
is_correct_password = secrets.compare_digest(
|
||||
current_password_bytes, correct_password_bytes
|
||||
)
|
||||
if not (is_correct_username and is_correct_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def no_verification():
|
||||
return True
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=app_lifespan,
|
||||
version=__version__,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 允许访问的源
|
||||
allow_credentials=False, # 支持 cookie
|
||||
allow_methods=["*"], # 允许使用的请求方法
|
||||
allow_headers=["*"], # 允许携带的 Headers
|
||||
)
|
||||
|
||||
|
||||
def reset_http_server():
|
||||
log.info(f"disable_httpauth:{config.disable_httpauth}")
|
||||
if config.disable_httpauth:
|
||||
app.dependency_overrides[verification] = no_verification
|
||||
else:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class AuthStaticFiles(StaticFiles):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
request = Request(scope, receive)
|
||||
if not config.disable_httpauth:
|
||||
assert verification(await security(request))
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
def HttpInit(_xiaomusic):
|
||||
global xiaomusic, config, log
|
||||
xiaomusic = _xiaomusic
|
||||
config = xiaomusic.config
|
||||
log = xiaomusic.log
|
||||
|
||||
folder = os.path.dirname(__file__)
|
||||
app.mount("/static", AuthStaticFiles(directory=f"{folder}/static"), name="static")
|
||||
reset_http_server()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def read_index(Verifcation=Depends(verification)):
|
||||
folder = os.path.dirname(__file__)
|
||||
return FileResponse(f"{folder}/static/index.html")
|
||||
|
||||
|
||||
@app.get("/getversion")
|
||||
def getversion(Verifcation=Depends(verification)):
|
||||
log.debug("getversion %s", __version__)
|
||||
return {"version": __version__}
|
||||
|
||||
|
||||
@app.get("/getvolume")
|
||||
async def getvolume(did: str = "", Verifcation=Depends(verification)):
|
||||
if not xiaomusic.did_exist(did):
|
||||
return {"volume": 0}
|
||||
|
||||
volume = await xiaomusic.get_volume(did=did)
|
||||
return {"volume": volume}
|
||||
|
||||
|
||||
class DidVolume(BaseModel):
|
||||
did: str
|
||||
volume: int = 0
|
||||
|
||||
|
||||
@app.post("/setvolume")
|
||||
async def setvolume(data: DidVolume, Verifcation=Depends(verification)):
|
||||
did = data.did
|
||||
volume = data.volume
|
||||
if not xiaomusic.did_exist(did):
|
||||
return {"ret": "Did not exist"}
|
||||
|
||||
log.info(f"set_volume {did} {volume}")
|
||||
await xiaomusic.set_volume(did=did, arg1=volume)
|
||||
return {"ret": "OK", "volume": volume}
|
||||
|
||||
|
||||
@app.get("/searchmusic")
|
||||
def searchmusic(name: str = "", Verifcation=Depends(verification)):
|
||||
return xiaomusic.searchmusic(name)
|
||||
|
||||
|
||||
@app.get("/playingmusic")
|
||||
def playingmusic(did: str = "", Verifcation=Depends(verification)):
|
||||
if not xiaomusic.did_exist(did):
|
||||
return {"ret": "Did not exist"}
|
||||
|
||||
is_playing = xiaomusic.isplaying(did)
|
||||
cur_music = xiaomusic.playingmusic(did)
|
||||
cur_playlist = xiaomusic.get_cur_play_list(did)
|
||||
# 播放进度
|
||||
offset, duration = xiaomusic.get_offset_duration(did)
|
||||
return {
|
||||
"ret": "OK",
|
||||
"is_playing": is_playing,
|
||||
"cur_music": cur_music,
|
||||
"cur_playlist": cur_playlist,
|
||||
"offset": offset,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
|
||||
class DidCmd(BaseModel):
|
||||
did: str
|
||||
cmd: str
|
||||
|
||||
|
||||
@app.post("/cmd")
|
||||
async def do_cmd(data: DidCmd, Verifcation=Depends(verification)):
|
||||
did = data.did
|
||||
cmd = data.cmd
|
||||
log.info(f"docmd. did:{did} cmd:{cmd}")
|
||||
if not xiaomusic.did_exist(did):
|
||||
return {"ret": "Did not exist"}
|
||||
|
||||
if len(cmd) > 0:
|
||||
try:
|
||||
await xiaomusic.cancel_all_tasks()
|
||||
task = asyncio.create_task(xiaomusic.do_check_cmd(did=did, query=cmd))
|
||||
xiaomusic.append_running_task(task)
|
||||
except Exception as e:
|
||||
log.warning(f"Execption {e}")
|
||||
return {"ret": "OK"}
|
||||
return {"ret": "Unknow cmd"}
|
||||
|
||||
|
||||
@app.get("/cmdstatus")
|
||||
async def cmd_status(Verifcation=Depends(verification)):
|
||||
finish = await xiaomusic.is_task_finish()
|
||||
if finish:
|
||||
return {"ret": "OK", "status": "finish"}
|
||||
return {"ret": "OK", "status": "running"}
|
||||
|
||||
|
||||
@app.get("/getsetting")
|
||||
async def getsetting(need_device_list: bool = False, Verifcation=Depends(verification)):
|
||||
config = xiaomusic.getconfig()
|
||||
data = asdict(config)
|
||||
data["password"] = "******"
|
||||
data["httpauth_password"] = "******"
|
||||
if need_device_list:
|
||||
device_list = await xiaomusic.getalldevices()
|
||||
log.info(f"getsetting device_list: {device_list}")
|
||||
data["device_list"] = device_list
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/savesetting")
|
||||
async def savesetting(request: Request, Verifcation=Depends(verification)):
|
||||
try:
|
||||
data_json = await request.body()
|
||||
data = json.loads(data_json.decode("utf-8"))
|
||||
debug_data = deepcopy_data_no_sensitive_info(data)
|
||||
log.info(f"saveconfig: {debug_data}")
|
||||
config = xiaomusic.getconfig()
|
||||
if data["password"] == "******" or data["password"] == "":
|
||||
data["password"] = config.password
|
||||
if data["httpauth_password"] == "******" or data["httpauth_password"] == "":
|
||||
data["httpauth_password"] = config.httpauth_password
|
||||
await xiaomusic.saveconfig(data)
|
||||
reset_http_server()
|
||||
return "save success"
|
||||
except json.JSONDecodeError as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from err
|
||||
|
||||
|
||||
@app.get("/musiclist")
|
||||
async def musiclist(Verifcation=Depends(verification)):
|
||||
return xiaomusic.get_music_list()
|
||||
|
||||
|
||||
@app.get("/musicinfo")
|
||||
async def musicinfo(
|
||||
name: str, musictag: bool = False, Verifcation=Depends(verification)
|
||||
):
|
||||
url = xiaomusic.get_music_url(name)
|
||||
info = {
|
||||
"ret": "OK",
|
||||
"name": name,
|
||||
"url": url,
|
||||
}
|
||||
if musictag:
|
||||
info["tags"] = xiaomusic.get_music_tags(name)
|
||||
return info
|
||||
|
||||
|
||||
@app.get("/musicinfos")
|
||||
async def musicinfos(
|
||||
name: list[str] = Query(None),
|
||||
musictag: bool = False,
|
||||
Verifcation=Depends(verification),
|
||||
):
|
||||
ret = []
|
||||
for music_name in name:
|
||||
url = xiaomusic.get_music_url(music_name)
|
||||
info = {
|
||||
"name": music_name,
|
||||
"url": url,
|
||||
}
|
||||
if musictag:
|
||||
info["tags"] = xiaomusic.get_music_tags(music_name)
|
||||
ret.append(info)
|
||||
return ret
|
||||
|
||||
|
||||
@app.get("/curplaylist")
|
||||
async def curplaylist(did: str = "", Verifcation=Depends(verification)):
|
||||
if not xiaomusic.did_exist(did):
|
||||
return ""
|
||||
return xiaomusic.get_cur_play_list(did)
|
||||
|
||||
|
||||
class MusicItem(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@app.post("/delmusic")
|
||||
def delmusic(data: MusicItem, Verifcation=Depends(verification)):
|
||||
log.info(data)
|
||||
xiaomusic.del_music(data.name)
|
||||
return "success"
|
||||
|
||||
|
||||
class UrlInfo(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@app.post("/downloadjson")
|
||||
async def downloadjson(data: UrlInfo, Verifcation=Depends(verification)):
|
||||
log.info(data)
|
||||
url = data.url
|
||||
content = ""
|
||||
try:
|
||||
ret = "OK"
|
||||
content = await downloadfile(url)
|
||||
except Exception as e:
|
||||
log.exception(f"Execption {e}")
|
||||
ret = "Download JSON file failed."
|
||||
return {
|
||||
"ret": ret,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/downloadlog")
|
||||
def downloadlog(Verifcation=Depends(verification)):
|
||||
file_path = xiaomusic.config.log_file
|
||||
if os.path.exists(file_path):
|
||||
# 创建一个临时文件来保存日志的快照
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
shutil.copyfileobj(f, temp_file)
|
||||
temp_file.close()
|
||||
|
||||
# 使用BackgroundTask在响应发送完毕后删除临时文件
|
||||
def cleanup_temp_file(tmp_file_path):
|
||||
os.remove(tmp_file_path)
|
||||
|
||||
background_task = BackgroundTask(cleanup_temp_file, temp_file.name)
|
||||
return FileResponse(
|
||||
temp_file.name,
|
||||
media_type="text/plain",
|
||||
filename="xiaomusic.txt",
|
||||
background=background_task,
|
||||
)
|
||||
except Exception as e:
|
||||
os.remove(temp_file.name)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error capturing log file"
|
||||
) from e
|
||||
else:
|
||||
return {"message": "File not found."}
|
||||
|
||||
|
||||
@app.get("/playurl")
|
||||
async def playurl(did: str, url: str, Verifcation=Depends(verification)):
|
||||
if not xiaomusic.did_exist(did):
|
||||
return {"ret": "Did not exist"}
|
||||
decoded_url = urllib.parse.unquote(url)
|
||||
log.info(f"playurl did: {did} url: {decoded_url}")
|
||||
return await xiaomusic.play_url(did=did, arg1=decoded_url)
|
||||
|
||||
|
||||
@app.post("/refreshmusictag")
|
||||
async def refreshmusictag(Verifcation=Depends(verification)):
|
||||
xiaomusic.refresh_music_tag()
|
||||
return {
|
||||
"ret": "OK",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/debug_play_by_music_url")
|
||||
async def debug_play_by_music_url(request: Request, Verifcation=Depends(verification)):
|
||||
try:
|
||||
data = await request.body()
|
||||
data_dict = json.loads(data.decode("utf-8"))
|
||||
log.info(f"data:{data_dict}")
|
||||
return await xiaomusic.debug_play_by_music_url(arg1=data_dict)
|
||||
except json.JSONDecodeError as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from err
|
||||
|
||||
|
||||
@app.get("/latestversion")
|
||||
async def latest_version(Verifcation=Depends(verification)):
|
||||
version = await get_latest_version("xiaomusic")
|
||||
if version:
|
||||
return {"ret": "OK", "version": version}
|
||||
else:
|
||||
return {"ret": "Fetch version failed"}
|
||||
|
||||
|
||||
class DownloadPlayList(BaseModel):
|
||||
dirname: str
|
||||
url: str
|
||||
|
||||
|
||||
# 下载歌单
|
||||
@app.post("/downloadplaylist")
|
||||
async def downloadplaylist(data: DownloadPlayList, Verifcation=Depends(verification)):
|
||||
try:
|
||||
download_proc = await download_playlist(config, data.url, data.dirname)
|
||||
|
||||
async def check_download_proc():
|
||||
# 等待子进程完成
|
||||
exit_code = await download_proc.wait()
|
||||
log.info(f"Download completed with exit code {exit_code}")
|
||||
|
||||
dir_path = os.path.join(config.download_path, data.dirname)
|
||||
log.debug(f"Download dir_path: {dir_path}")
|
||||
# 可能只是部分失败,都需要整理下载目录
|
||||
remove_common_prefix(dir_path)
|
||||
|
||||
asyncio.create_task(check_download_proc())
|
||||
return {"ret": "OK"}
|
||||
except Exception as e:
|
||||
log.exception(f"Execption {e}")
|
||||
|
||||
return {"ret": "Failed download"}
|
||||
|
||||
|
||||
class DownloadOneMusic(BaseModel):
|
||||
name: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
# 下载单首歌曲
|
||||
@app.post("/downloadonemusic")
|
||||
async def downloadonemusic(data: DownloadOneMusic, Verifcation=Depends(verification)):
|
||||
try:
|
||||
await download_one_music(config, data.url, data.name)
|
||||
return {"ret": "OK"}
|
||||
except Exception as e:
|
||||
log.exception(f"Execption {e}")
|
||||
|
||||
return {"ret": "Failed download"}
|
||||
|
||||
|
||||
# 上传 yt-dlp cookies
|
||||
@app.post("/uploadytdlpcookie")
|
||||
async def upload_yt_dlp_cookie(file: UploadFile = File(...)):
|
||||
with open(config.yt_dlp_cookies_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
return {
|
||||
"ret": "OK",
|
||||
"filename": file.filename,
|
||||
"file_location": config.yt_dlp_cookies_path,
|
||||
}
|
||||
|
||||
|
||||
async def file_iterator(file_path, start, end):
|
||||
async with aiofiles.open(file_path, mode="rb") as file:
|
||||
await file.seek(start)
|
||||
chunk_size = 1024
|
||||
while start <= end:
|
||||
read_size = min(chunk_size, end - start + 1)
|
||||
data = await file.read(read_size)
|
||||
if not data:
|
||||
break
|
||||
start += len(data)
|
||||
yield data
|
||||
|
||||
|
||||
def access_key_verification(file_path, key, code):
|
||||
if config.disable_httpauth:
|
||||
return True
|
||||
|
||||
log.debug(f"访问限制接收端[{file_path}, {key}, {code}]")
|
||||
if key is not None:
|
||||
current_key_bytes = key.encode("utf8")
|
||||
correct_key_bytes = (
|
||||
config.httpauth_username + config.httpauth_password
|
||||
).encode("utf8")
|
||||
is_correct_key = secrets.compare_digest(correct_key_bytes, current_key_bytes)
|
||||
if is_correct_key:
|
||||
return True
|
||||
|
||||
if code is not None:
|
||||
current_code_bytes = code.encode("utf8")
|
||||
correct_code_bytes = (
|
||||
hashlib.sha256(
|
||||
(
|
||||
file_path + config.httpauth_username + config.httpauth_password
|
||||
).encode("utf-8")
|
||||
)
|
||||
.hexdigest()
|
||||
.encode("utf-8")
|
||||
)
|
||||
is_correct_code = secrets.compare_digest(correct_code_bytes, current_code_bytes)
|
||||
if is_correct_code:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
range_pattern = re.compile(r"bytes=(\d+)-(\d*)")
|
||||
|
||||
|
||||
def safe_redirect(url):
|
||||
url = try_add_access_control_param(config, url)
|
||||
url = url.replace("\\", "")
|
||||
if not urllib.parse.urlparse(url).netloc and not urllib.parse.urlparse(url).scheme:
|
||||
log.debug(f"redirect to {url}")
|
||||
return RedirectResponse(url=url)
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/music/{file_path:path}")
|
||||
async def music_file(request: Request, file_path: str, key: str = "", code: str = ""):
|
||||
if not access_key_verification(f"/music/{file_path}", key, code):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
absolute_path = os.path.abspath(config.music_path)
|
||||
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
|
||||
if not absolute_file_path.startswith(absolute_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not os.path.exists(absolute_file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# 移除MP3 ID3 v2标签和填充,减少播放前延迟
|
||||
if config.remove_id3tag and is_mp3(file_path):
|
||||
log.info(f"remove_id3tag:{config.remove_id3tag}, is_mp3:True ")
|
||||
temp_mp3_file = remove_id3_tags(absolute_file_path, config)
|
||||
if temp_mp3_file:
|
||||
log.info(f"ID3 tag removed {absolute_file_path} to {temp_mp3_file}")
|
||||
redirect = safe_redirect(f"/music/{temp_mp3_file}")
|
||||
if redirect:
|
||||
return redirect
|
||||
else:
|
||||
log.info(f"No ID3 tag remove needed: {absolute_file_path}")
|
||||
|
||||
if config.convert_to_mp3 and not is_mp3(file_path):
|
||||
temp_mp3_file = convert_file_to_mp3(absolute_file_path, config)
|
||||
if temp_mp3_file:
|
||||
log.info(f"Converted file: {absolute_file_path} to {temp_mp3_file}")
|
||||
redirect = safe_redirect(f"/music/{temp_mp3_file}")
|
||||
if redirect:
|
||||
return redirect
|
||||
else:
|
||||
log.warning(f"Failed to convert file to MP3 format: {absolute_file_path}")
|
||||
|
||||
file_size = os.path.getsize(absolute_file_path)
|
||||
range_start, range_end = 0, file_size - 1
|
||||
|
||||
range_header = request.headers.get("Range")
|
||||
log.info(f"music_file range_header {range_header}")
|
||||
if range_header:
|
||||
range_match = range_pattern.match(range_header)
|
||||
if range_match:
|
||||
range_start = int(range_match.group(1))
|
||||
if range_match.group(2):
|
||||
range_end = int(range_match.group(2))
|
||||
|
||||
log.info(f"music_file in range {absolute_file_path}")
|
||||
|
||||
log.info(f"music_file {range_start} {range_end} {absolute_file_path}")
|
||||
headers = {
|
||||
"Content-Range": f"bytes {range_start}-{range_end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
}
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if mime_type is None:
|
||||
mime_type = "application/octet-stream"
|
||||
return StreamingResponse(
|
||||
file_iterator(absolute_file_path, range_start, range_end),
|
||||
headers=headers,
|
||||
status_code=206 if range_header else 200,
|
||||
media_type=mime_type,
|
||||
)
|
||||
|
||||
|
||||
@app.options("/music/{file_path:path}")
|
||||
async def music_options():
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
}
|
||||
return Response(headers=headers)
|
||||
|
||||
|
||||
@app.get("/picture/{file_path:path}")
|
||||
async def get_picture(request: Request, file_path: str, key: str = "", code: str = ""):
|
||||
if not access_key_verification(f"/picture/{file_path}", key, code):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
absolute_path = os.path.abspath(config.picture_cache_path)
|
||||
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
|
||||
if not absolute_file_path.startswith(absolute_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not os.path.exists(absolute_file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(absolute_file_path)
|
||||
if mime_type is None:
|
||||
mime_type = "image/jpeg"
|
||||
return FileResponse(absolute_file_path, media_type=mime_type)
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def get_swagger_documentation(Verifcation=Depends(verification)):
|
||||
return get_swagger_ui_html(openapi_url="/openapi.json", title="docs")
|
||||
|
||||
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
async def get_redoc_documentation(Verifcation=Depends(verification)):
|
||||
return get_redoc_html(openapi_url="/openapi.json", title="docs")
|
||||
|
||||
|
||||
@app.get("/openapi.json", include_in_schema=False)
|
||||
async def openapi(Verifcation=Depends(verification)):
|
||||
return get_openapi(title=app.title, version=app.version, routes=app.routes)
|
||||
69
xiaomusic/plugin.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, xiaomusic, plugin_dir="plugins"):
|
||||
self.xiaomusic = xiaomusic
|
||||
self.log = xiaomusic.log
|
||||
self._funcs = {}
|
||||
self._load_plugins(plugin_dir)
|
||||
|
||||
def _load_plugins(self, plugin_dir):
|
||||
# 假设 plugins 已经在搜索路径上
|
||||
package_name = plugin_dir
|
||||
package = importlib.import_module(package_name)
|
||||
|
||||
# 遍历 package 中所有模块并动态导入它们
|
||||
for _, modname, _ in pkgutil.iter_modules(package.__path__, package_name + "."):
|
||||
# 跳过__init__文件
|
||||
if modname.endswith("__init__"):
|
||||
continue
|
||||
module = importlib.import_module(modname)
|
||||
# 将 log 和 xiaomusic 注入模块的命名空间
|
||||
module.log = self.log
|
||||
module.xiaomusic = self.xiaomusic
|
||||
|
||||
# 动态获取模块中与文件名同名的函数
|
||||
function_name = modname.split(".")[-1] # 从模块全名提取函数名
|
||||
if hasattr(module, function_name):
|
||||
self._funcs[function_name] = getattr(module, function_name)
|
||||
else:
|
||||
self.log.error(
|
||||
f"No function named '{function_name}' found in module {modname}"
|
||||
)
|
||||
|
||||
def get_func(self, plugin_name):
|
||||
"""根据插件名获取插件函数"""
|
||||
return self._funcs.get(plugin_name)
|
||||
|
||||
def get_local_namespace(self):
|
||||
"""返回包含所有插件函数的字典,可以用作 exec 要执行的代码的命名空间"""
|
||||
return self._funcs.copy()
|
||||
|
||||
async def execute_plugin(self, code):
|
||||
"""
|
||||
执行指定的插件代码。插件函数可以是同步或异步。
|
||||
:param code: 需要执行的插件函数代码(例如 'plugin1("hello")')
|
||||
"""
|
||||
# 分解代码字符串以获取函数名
|
||||
func_name = code.split("(")[0]
|
||||
|
||||
# 根据解析出的函数名从插件字典中获取函数
|
||||
plugin_func = self.get_func(func_name)
|
||||
|
||||
if not plugin_func:
|
||||
raise ValueError(f"No plugin function named '{func_name}' found.")
|
||||
|
||||
# 检查函数是否是异步函数
|
||||
global_namespace = globals().copy()
|
||||
local_namespace = self.get_local_namespace()
|
||||
if inspect.iscoroutinefunction(plugin_func):
|
||||
# 如果是异步函数,构建执行用的协程对象
|
||||
coroutine = eval(code, global_namespace, local_namespace)
|
||||
# 等待协程执行
|
||||
await coroutine
|
||||
else:
|
||||
# 如果是普通函数,直接执行代码
|
||||
eval(code, global_namespace, local_namespace)
|
||||
422
xiaomusic/static/default/app.js
Normal file
@@ -0,0 +1,422 @@
|
||||
$(function(){
|
||||
$container=$("#cmds");
|
||||
|
||||
append_op_button_name("加入收藏");
|
||||
append_op_button_name("取消收藏");
|
||||
|
||||
const PLAY_TYPE_ONE = 0; // 单曲循环
|
||||
const PLAY_TYPE_ALL = 1; // 全部循环
|
||||
const PLAY_TYPE_RND = 2; // 随机播放
|
||||
append_op_button("play_type_all", "全部循环", "全部循环");
|
||||
append_op_button("play_type_one", "单曲循环", "单曲循环");
|
||||
append_op_button("play_type_rnd", "随机播放", "随机播放");
|
||||
|
||||
append_op_button_name("上一首");
|
||||
append_op_button_name("关机");
|
||||
append_op_button_name("下一首");
|
||||
|
||||
append_op_button_name("刷新列表");
|
||||
|
||||
$container.append($("<hr>"));
|
||||
|
||||
append_op_button_name("10分钟后关机");
|
||||
append_op_button_name("30分钟后关机");
|
||||
append_op_button_name("60分钟后关机");
|
||||
|
||||
var offset = 0;
|
||||
var duration = 0;
|
||||
|
||||
// 拉取现有配置
|
||||
$.get("/getsetting", function(data, status) {
|
||||
console.log(data, status);
|
||||
localStorage.setItem('mi_did', data.mi_did);
|
||||
|
||||
var did = localStorage.getItem('cur_did');
|
||||
var dids = [];
|
||||
if (data.mi_did != null) {
|
||||
dids = data.mi_did.split(',');
|
||||
}
|
||||
console.log('cur_did', did);
|
||||
console.log('dids', dids);
|
||||
if ((dids.length > 0) && (did == null || did == "" || !dids.includes(did))) {
|
||||
did = dids[0];
|
||||
localStorage.setItem('cur_did', did);
|
||||
}
|
||||
|
||||
window.did = did;
|
||||
$.get(`/getvolume?did=${did}`, function(data, status) {
|
||||
console.log(data, status, data["volume"]);
|
||||
$("#volume").val(data.volume);
|
||||
});
|
||||
refresh_music_list();
|
||||
|
||||
$("#did").empty();
|
||||
var dids = data.mi_did.split(',');
|
||||
$.each(dids, function(index, value) {
|
||||
var cur_device = Object.values(data.devices).find(device => device.did === value);
|
||||
if (cur_device) {
|
||||
var option = $('<option></option>')
|
||||
.val(value)
|
||||
.text(cur_device.name)
|
||||
.prop('selected', value === did);
|
||||
$("#did").append(option);
|
||||
|
||||
if (value === did) {
|
||||
if (cur_device.play_type == PLAY_TYPE_ALL) {
|
||||
$("#play_type_all").css('background-color', '#b1a8f3');
|
||||
$("#play_type_all").text('✔️ 全部循环');
|
||||
} else if (cur_device.play_type == PLAY_TYPE_ONE) {
|
||||
$("#play_type_one").css('background-color', '#b1a8f3');
|
||||
$("#play_type_one").text('✔️ 单曲循环');
|
||||
} else if (cur_device.play_type == PLAY_TYPE_RND) {
|
||||
$("#play_type_rnd").css('background-color', '#b1a8f3');
|
||||
$("#play_type_rnd").text('✔️ 随机播放');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('cur_did', did);
|
||||
$('#did').change(function() {
|
||||
did = $(this).val();
|
||||
localStorage.setItem('cur_did', did);
|
||||
window.did = did;
|
||||
console.log('cur_did', did);
|
||||
location.reload();
|
||||
})
|
||||
});
|
||||
|
||||
function compareVersion(version1, version2) {
|
||||
const v1 = version1.split('.').map(Number);
|
||||
const v2 = version2.split('.').map(Number);
|
||||
const len = Math.max(v1.length, v2.length);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const num1 = v1[i] || 0;
|
||||
const num2 = v2[i] || 0;
|
||||
if (num1 > num2) return 1;
|
||||
if (num1 < num2) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 拉取版本
|
||||
$.get("/getversion", function(data, status) {
|
||||
console.log(data, status, data["version"]);
|
||||
$("#version").text(`${data.version}`);
|
||||
|
||||
$.get("/latestversion", function(ret, status) {
|
||||
console.log(ret, status);
|
||||
if (ret.ret == "OK") {
|
||||
const result = compareVersion(ret.version, data.version);
|
||||
if (result > 0) {
|
||||
console.log(`${ret.version} is greater than ${data.version}`);
|
||||
$("#versionnew").text("🆕");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function _refresh_music_list(callback) {
|
||||
$('#music_list').empty();
|
||||
$.get("/musiclist", function(data, status) {
|
||||
console.log(data, status);
|
||||
$.each(data, function(key, value) {
|
||||
$('#music_list').append($('<option></option>').val(key).text(key));
|
||||
});
|
||||
|
||||
$('#music_list').change(function() {
|
||||
const selectedValue = $(this).val();
|
||||
localStorage.setItem('cur_playlist', selectedValue);
|
||||
$('#music_name').empty();
|
||||
$.each(data[selectedValue], function(index, item) {
|
||||
$('#music_name').append($('<option></option>').val(item).text(item));
|
||||
});
|
||||
});
|
||||
|
||||
$('#music_list').trigger('change');
|
||||
|
||||
// 获取当前播放列表
|
||||
$.get(`/curplaylist?did=${did}`, function(playlist, status) {
|
||||
if (playlist != "") {
|
||||
$('#music_list').val(playlist);
|
||||
$('#music_list').trigger('change');
|
||||
} else {
|
||||
// 使用本地记录的
|
||||
playlist = localStorage.getItem('cur_playlist');
|
||||
if (data.includes(playlist)) {
|
||||
$('#music_list').val(playlist);
|
||||
$('#music_list').trigger('change');
|
||||
}
|
||||
}
|
||||
})
|
||||
callback();
|
||||
})
|
||||
}
|
||||
|
||||
// 拉取播放列表
|
||||
function refresh_music_list() {
|
||||
// 刷新列表时清空并临时禁用搜索框
|
||||
const searchInput = document.getElementById('search');
|
||||
const oriPlaceHolder = searchInput.placeholder
|
||||
const oriValue = searchInput.value
|
||||
const inputEvent = new Event('input', { bubbles: true });
|
||||
searchInput.value = '';
|
||||
// 分发事件,让其他控件改变状态
|
||||
searchInput.dispatchEvent(inputEvent);
|
||||
searchInput.disabled = true;
|
||||
searchInput.placeholder = '请等待...';
|
||||
|
||||
_refresh_music_list(() => {
|
||||
// 刷新完成再启用
|
||||
searchInput.disabled = false;
|
||||
searchInput.value = oriValue
|
||||
searchInput.dispatchEvent(inputEvent);
|
||||
searchInput.placeholder = oriPlaceHolder;
|
||||
// 每3秒获取下正在播放的音乐
|
||||
get_playing_music();
|
||||
setInterval(() => {
|
||||
get_playing_music();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
$("#play_music_list").on("click", () => {
|
||||
var music_list = $("#music_list").val();
|
||||
var music_name = $("#music_name").val();
|
||||
let cmd = "播放列表" + music_list + "|" + music_name;
|
||||
sendcmd(cmd);
|
||||
});
|
||||
|
||||
$("#web_play").on("click", () => {
|
||||
const music_name = $("#music_name").val();
|
||||
$.get(`/musicinfo?name=${music_name}`, function(data, status) {
|
||||
console.log(data);
|
||||
if (data.ret == "OK") {
|
||||
$('audio').attr('src',data.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#del_music").on("click", () => {
|
||||
var del_music_name = $("#music_name").val();
|
||||
if (confirm(`确定删除歌曲 ${del_music_name} 吗?`)) {
|
||||
console.log(`删除歌曲 ${del_music_name}`);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/delmusic',
|
||||
data: JSON.stringify({"name": del_music_name}),
|
||||
contentType: "application/json; charset=utf-8",
|
||||
success: () => {
|
||||
alert(`删除 ${del_music_name} 成功`);
|
||||
refresh_music_list();
|
||||
},
|
||||
error: () => {
|
||||
alert(`删除 ${del_music_name} 失败`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$("#playurl").on("click", () => {
|
||||
var url = $("#music-url").val();
|
||||
const encoded_url = encodeURIComponent(url);
|
||||
$.get(`/playurl?url=${encoded_url}&did=${did}`, function(data, status) {
|
||||
console.log(data);
|
||||
});
|
||||
});
|
||||
|
||||
function append_op_button_name(name) {
|
||||
append_op_button(null, name, name);
|
||||
}
|
||||
|
||||
function append_op_button(id, name, cmd) {
|
||||
// 创建按钮
|
||||
const $button = $("<button>");
|
||||
$button.text(name);
|
||||
$button.attr("type", "button");
|
||||
if (id !== null) {
|
||||
$button.attr("id", id);
|
||||
}
|
||||
|
||||
// 设置按钮点击事件
|
||||
$button.on("click", () => {
|
||||
sendcmd(cmd);
|
||||
});
|
||||
|
||||
// 添加按钮到容器
|
||||
$container.append($button);
|
||||
}
|
||||
|
||||
$("#play").on("click", () => {
|
||||
var search_key = $("#music-name").val();
|
||||
var filename = $("#music-filename").val();
|
||||
let cmd = "播放歌曲" + search_key + "|" + filename;
|
||||
sendcmd(cmd);
|
||||
});
|
||||
|
||||
$("#volume").on('change', function () {
|
||||
var value = $(this).val();
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/setvolume",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
data: JSON.stringify({did: did, volume: value}),
|
||||
success: () => {
|
||||
},
|
||||
error: () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function check_status_refresh_music_list(retries) {
|
||||
$.get("/cmdstatus", function(data) {
|
||||
if (data.status === "finish") {
|
||||
refresh_music_list();
|
||||
} else if (retries > 0) {
|
||||
setTimeout(function() {
|
||||
check_status_refresh_music_list(retries - 1);
|
||||
}, 1000); // 等待1秒后重试
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendcmd(cmd) {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/cmd",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
data: JSON.stringify({did: did, cmd: cmd}),
|
||||
success: () => {
|
||||
if (cmd == "刷新列表") {
|
||||
check_status_refresh_music_list(3); // 最多重试3次
|
||||
}
|
||||
if (["全部循环", "单曲循环", "随机播放"].includes(cmd)) {
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// 请求失败时执行的操作
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 监听输入框的输入事件
|
||||
function debounce(func, delay) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('search');
|
||||
const musicSelect = document.getElementById('music-name');
|
||||
const musicSelectLabel = document.getElementById('music-name-label');
|
||||
|
||||
searchInput.addEventListener('input', debounce(function() {
|
||||
const query = searchInput.value.trim();
|
||||
|
||||
if (query.length === 0) {
|
||||
musicSelect.innerHTML = '';
|
||||
musicSelect.style.display = 'none'
|
||||
musicSelectLabel.style.display = 'none'
|
||||
return;
|
||||
}
|
||||
|
||||
musicSelect.style.display = 'block'
|
||||
musicSelectLabel.style.display = 'block'
|
||||
fetch(`/searchmusic?name=${encodeURIComponent(query)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
musicSelect.innerHTML = ''; // 清空现有选项
|
||||
|
||||
// 找到的优先显示
|
||||
if (data.length > 0) {
|
||||
data.forEach(song => {
|
||||
const option = document.createElement('option');
|
||||
option.value = song
|
||||
option.textContent = song
|
||||
musicSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户输入作为一个选项
|
||||
const userOption = document.createElement('option');
|
||||
userOption.value = query;
|
||||
userOption.textContent = `使用关键词播放: ${query}`;
|
||||
musicSelect.appendChild(userOption);
|
||||
|
||||
// 提示没找到
|
||||
if (data.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.textContent = '没有匹配的结果';
|
||||
option.disabled = true;
|
||||
musicSelect.appendChild(option);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching data:', error);
|
||||
});
|
||||
}, 300));
|
||||
|
||||
// 动态显示保存文件名输入框
|
||||
const musicNameSelect = document.getElementById('music-name');
|
||||
const musicFilenameInput = document.getElementById('music-filename');
|
||||
function updateInputVisibility() {
|
||||
const selectedOption = musicNameSelect.options[musicNameSelect.selectedIndex];
|
||||
var startsWithKeyword;
|
||||
if (musicNameSelect.options.length === 0) {
|
||||
startsWithKeyword = false;
|
||||
} else {
|
||||
startsWithKeyword = selectedOption.text.startsWith('使用关键词联网搜索:');
|
||||
}
|
||||
|
||||
if (startsWithKeyword) {
|
||||
musicFilenameInput.style.display = 'block';
|
||||
musicFilenameInput.placeholder = '请输入保存为的文件名称(默认:' + selectedOption.value + ')';
|
||||
} else {
|
||||
musicFilenameInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// 观察元素修改
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
updateInputVisibility()
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(musicNameSelect, { childList: true });
|
||||
// 监听用户输入
|
||||
musicNameSelect.addEventListener('change', updateInputVisibility);
|
||||
|
||||
function get_playing_music() {
|
||||
$.get(`/playingmusic?did=${did}`, function(data, status) {
|
||||
console.log(data);
|
||||
if (data.ret == "OK") {
|
||||
if (data.is_playing) {
|
||||
$("#playering-music").text(`【播放中】 ${data.cur_music}`);
|
||||
} else {
|
||||
$("#playering-music").text(`【空闲中】 ${data.cur_music}`);
|
||||
}
|
||||
offset = data.offset;
|
||||
duration = data.duration;
|
||||
}
|
||||
});
|
||||
}
|
||||
setInterval(()=>{
|
||||
if (duration > 0) {
|
||||
offset++;
|
||||
$("#progress").val(offset / duration * 100);
|
||||
$("#play-time").text(`${formatTime(offset)}/${formatTime(duration)}`)
|
||||
}else{
|
||||
$("#play-time").text(`${formatTime(0)}/${formatTime(0)}`)
|
||||
}
|
||||
},1000)
|
||||
function formatTime(seconds) {
|
||||
var minutes = Math.floor(seconds / 60);
|
||||
var remainingSeconds =Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
});
|
||||
71
xiaomusic/static/default/debug.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Debug For XiaoMusic</title>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="./style.css?version=1729000721">
|
||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||
<script src="./jquery-3.7.1.min.js?version=1729000721"></script>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
<script>
|
||||
var vConsole = new window.VConsole();
|
||||
|
||||
function postJSON() {
|
||||
var data = $('#post-input').val();
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/debug_play_by_music_url',
|
||||
data: data,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
success: (err) => {
|
||||
console.log("succ", res);
|
||||
},
|
||||
error: (res) => {
|
||||
console.log("error", res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendDebugCmd() {
|
||||
var cmd = $("#cmd").val();
|
||||
var did = localStorage.getItem('cur_did');
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/cmd",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
data: JSON.stringify({did: did, cmd: cmd}),
|
||||
success: () => {
|
||||
},
|
||||
error: () => {
|
||||
// 请求失败时执行的操作
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Debug For XiaoMusic</h1>
|
||||
<textarea id="post-input" rows="10" cols="50" placeholder="粘贴json数据..."></textarea><br>
|
||||
<button onclick="postJSON()">提交</button><br>
|
||||
|
||||
<hr>
|
||||
<input id="cmd" type="text"></input>
|
||||
<button onclick="sendDebugCmd()">测试自定义口令</button><br>
|
||||
</body>
|
||||
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
|
||||
</footer>
|
||||
</html>
|
||||
113
xiaomusic/static/default/downloadtool.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>歌曲下载工具</title>
|
||||
<link rel="stylesheet" type="text/css" href="./style.css?version=1729000721">
|
||||
<script src="./jquery-3.7.1.min.js?version=1729000721"></script>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>歌曲下载工具</h1>
|
||||
|
||||
<div class="rows">
|
||||
<!-- 歌单的输入 -->
|
||||
<label for="playlistUrl">输入歌单 URL:</label>
|
||||
<input type="text" id="playlistUrl" value="https://m.bilibili.com/video/BV1WUsDezE88">
|
||||
|
||||
<label for="dirname">输入歌单名字:</label>
|
||||
<input type="text" id="dirname" placeholder="流行歌曲">
|
||||
|
||||
<button id="downloadPlaylistBtn">下载歌单</button>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="rows">
|
||||
|
||||
<!-- 单曲的输入 -->
|
||||
<label for="songUrl">输入歌曲 URL:</label>
|
||||
<input type="text" id="songUrl" value="https://m.bilibili.com/video/BV1qD4y1U7fs">
|
||||
|
||||
<label for="songName">输入歌曲名字:</label>
|
||||
<input type="text" id="songName" placeholder="歌曲名">
|
||||
|
||||
<button id="downloadSongBtn">下载单曲</button>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// 下载歌单
|
||||
$('#downloadPlaylistBtn').click(function() {
|
||||
var playlistUrl = $('#playlistUrl').val();
|
||||
var dirname = $('#dirname').val();
|
||||
|
||||
if (!playlistUrl || !dirname) {
|
||||
alert('请填写完整的歌单 URL 和歌单名字');
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
dirname: dirname,
|
||||
url: playlistUrl
|
||||
};
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/downloadplaylist",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: (msg) => {
|
||||
alert('歌单下载请求已发送!');
|
||||
console.log(response);
|
||||
},
|
||||
error: (msg) => {
|
||||
alert('歌单下载请求失败,请重试。');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 下载单曲
|
||||
$('#downloadSongBtn').click(function() {
|
||||
var songName = $('#songName').val();
|
||||
var songUrl = $('#songUrl').val();
|
||||
|
||||
if (!songUrl || !songName) {
|
||||
alert('请填写完整的歌曲 URL 和歌曲名字');
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
name: songName,
|
||||
url: songUrl
|
||||
};
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/downloadonemusic",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: (msg) => {
|
||||
alert('单曲下载请求已发送!');
|
||||
console.log(response);
|
||||
},
|
||||
error: (msg) => {
|
||||
alert('单曲下载请求失败,请重试。');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
98
xiaomusic/static/default/index.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>小爱音箱操控面板</title>
|
||||
<script src="./jquery-3.7.1.min.js?version=1729000721"></script>
|
||||
<script src="./app.js?version=1729000721"></script>
|
||||
<link rel="stylesheet" type="text/css" href="./style.css?version=1729000721">
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
<!--
|
||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||
<script>
|
||||
var vConsole = new window.VConsole();
|
||||
</script>
|
||||
-->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<h2>小爱音箱操控面板
|
||||
(<a id="version" href="https://github.com/hanxi/xiaomusic/blob/main/CHANGELOG.md">版本未知</a>)
|
||||
<span id="versionnew" class="blink"></span>
|
||||
</h2>
|
||||
<hr>
|
||||
|
||||
<div class="rows">
|
||||
<select id="did">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="cmds">
|
||||
<a class="button" href="./setting.html">设置</a>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div style="margin: 20px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#8e43e7" style="height: 48px; width: 48px;"><path d="M550.826667 154.666667a47.786667 47.786667 0 0 0-19.84 4.48L298.666667 298.666667H186.453333A80 80 0 0 0 106.666667 378.453333v267.093334A80 80 0 0 0 186.453333 725.333333H298.666667l232.32 139.52a47.786667 47.786667 0 0 0 19.84 4.48A46.506667 46.506667 0 0 0 597.333333 822.826667V201.173333a46.506667 46.506667 0 0 0-46.506666-46.506666zM554.666667 822.826667c0 3.413333-3.84 3.84-3.84 3.84L320 688.853333l-9.6-6.186666H186.453333A37.12 37.12 0 0 1 149.333333 645.546667V378.453333A37.12 37.12 0 0 1 186.453333 341.333333h123.946667l10.24-6.186666 229.546667-137.6s3.84 0 3.84 3.84zM667.52 346.026667a21.333333 21.333333 0 0 0 0 30.293333 192 192 0 0 1 0 271.36 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 234.666667 234.666667 0 0 0 0-331.946666 21.333333 21.333333 0 0 0-30.293333 0z"></path><path d="M804.48 219.52a21.333333 21.333333 0 0 0-30.293333 30.293333 370.986667 370.986667 0 0 1 0 524.373334 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 414.08 414.08 0 0 0 0-584.96z"></path></svg>
|
||||
<input id="volume" type="range"></input>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="rows">
|
||||
<label for="search">搜索歌曲:</label>
|
||||
<input type="text" id="search" placeholder="请输入搜索关键词(如:MV高清版 周杰伦 七里香)">
|
||||
|
||||
<label for="music-name" id="music-name-label" style="display: none;">确认选择:</label>
|
||||
<select id="music-name" style="display: none;">
|
||||
<!-- 动态生成选项 -->
|
||||
</select>
|
||||
|
||||
<input id="music-filename" type="text" placeholder="请输入保存为的文件名称(如:周杰伦七里香)" style="display: none;"></input>
|
||||
<div style="display: flex; align-items: center">
|
||||
<progress id="progress" value="0" max="100" style="width: 270px"></progress>
|
||||
<div id="play-time" style="margin-left: 10px">00:00/00:00</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="play">播放</button>
|
||||
<div id="playering-music" class="text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="rows">
|
||||
<label for="music_list">播放列表:</label>
|
||||
<select id="music_list"></select>
|
||||
<label for="music_name">歌曲:</label>
|
||||
<select id="music_name"></select>
|
||||
<div>
|
||||
<button id="play_music_list">播放选中歌曲</button>
|
||||
<button id="del_music">删除选中歌曲</button>
|
||||
<button id="web_play">网页播放</button>
|
||||
</div>
|
||||
<div class="play_pannel">
|
||||
<audio autoplay controls src=""></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="rows">
|
||||
<input id="music-url" type="text" value="https://lhttp.qtfm.cn/live/4915/64k.mp3"></input>
|
||||
<button id="playurl">播放链接</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
2
xiaomusic/static/default/jquery-3.7.1.min.js
vendored
Normal file
75
xiaomusic/static/default/m3u.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>M3U to JSON Converter</title>
|
||||
<link rel="stylesheet" type="text/css" href="./style.css?version=1729000721">
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
<!--
|
||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||
<script>
|
||||
// VConsole 默认会挂载到 `window.VConsole` 上
|
||||
var vConsole = new window.VConsole();
|
||||
</script>
|
||||
-->
|
||||
<script>
|
||||
function handleFileSelect(evt) {
|
||||
var file = evt.target.files[0];
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('m3u-input').value = e.target.result;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
alert('无法加载文件');
|
||||
}
|
||||
}
|
||||
|
||||
function convertToJSON() {
|
||||
var m3uContent = document.getElementById('m3u-input').value;
|
||||
var lines = m3uContent.split('\n');
|
||||
console.log(lines);
|
||||
var musicsArray = [];
|
||||
var currentName = '';
|
||||
lines.forEach(function(line) {
|
||||
line = line.trim();
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
currentName = line.replace(/.*,/g, '');
|
||||
} else if (line.startsWith('http') && currentName !== '') {
|
||||
musicsArray.push({"name": currentName, "type": "radio", "url": line});
|
||||
currentName = ''; // Reset the name for the next entry
|
||||
}
|
||||
});
|
||||
var output = [{
|
||||
"name": "m3u电台",
|
||||
"musics": musicsArray
|
||||
}];
|
||||
|
||||
document.getElementById('json-output').value = JSON.stringify(output, null, 2);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>M3U to JSON Converter</h1>
|
||||
<input type="file" id="file-input" accept=".m3u" onchange="handleFileSelect(event)"/><br>
|
||||
<textarea id="m3u-input" rows="10" cols="50" placeholder="粘贴m3u内容或上传文件..."></textarea><br>
|
||||
<button onclick="convertToJSON()">转换</button><br>
|
||||
<textarea id="json-output" rows="10" cols="50" placeholder="转换后的JSON..."></textarea>
|
||||
</body>
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
|
||||
</footer>
|
||||
</html>
|
||||
|
||||
BIN
xiaomusic/static/default/qrcode.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
238
xiaomusic/static/default/setting.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>小爱音箱操控面板</title>
|
||||
<script src="./jquery-3.7.1.min.js?version=1729000721"></script>
|
||||
<script src="./setting.js?version=1729000721"></script>
|
||||
<link rel="stylesheet" type="text/css" href="./style.css?version=1729000721">
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
<!--
|
||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||
<script>
|
||||
var vConsole = new window.VConsole();
|
||||
</script>
|
||||
-->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<h2>小爱音箱设置面板
|
||||
(<a id="version" href="https://github.com/hanxi/xiaomusic/blob/main/CHANGELOG.md">
|
||||
版本未知
|
||||
</a>)
|
||||
</h2>
|
||||
<hr>
|
||||
|
||||
<div class="rows">
|
||||
<label for="mi_did">*勾选设备(至少勾选1个):</label>
|
||||
<div id="mi_did">
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div id="setting">
|
||||
<div class="rows">
|
||||
<label for="account">*小米账号:</label>
|
||||
<input id="account" type="text" placeholder="填写小米登录账号"></input>
|
||||
|
||||
<label for="password">*小米密码:</label>
|
||||
<input id="password" type="password" placeholder="填写小米登录密码"></input>
|
||||
|
||||
<label for="hostname">*XIAOMUSIC_HOSTNAME(IP或域名):</label>
|
||||
<input id="hostname" type="text"></input>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="rows">
|
||||
<label for="verbose">是否开启调试日志:</label>
|
||||
<select id="verbose">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
|
||||
<label for="group_list">设备分组配置:</label>
|
||||
<input id="group_list" type="text" placeholder="did1:组名1,did2:组名1,did3:组名2"></input>
|
||||
|
||||
<label for="music_path">音乐目录:</label>
|
||||
<input id="music_path" type="text" value="music"></input>
|
||||
|
||||
<label for="download_path">音乐下载目录(必须是music的子目录):</label>
|
||||
<input id="download_path" type="text" value='music/download'></input>
|
||||
|
||||
<label for="conf_path">配置文件目录:</label>
|
||||
<input id="conf_path" type="text"></input>
|
||||
|
||||
|
||||
<label for="cache_dir">缓存文件目录:</label>
|
||||
<input id="cache_dir" type="text"></input>
|
||||
|
||||
<label for="ffmpeg_location">ffmpeg路径:</label>
|
||||
<input id="ffmpeg_location" type="text" value="./ffmpeg/bin"></input>
|
||||
|
||||
<label for="log_file">日志路径:</label>
|
||||
<input id="log_file" type="text" value="/tmp/xiaomusic.txt"></input>
|
||||
|
||||
<label for="active_cmd">允许唤醒的命令:</label>
|
||||
<input id="active_cmd" type="text" value="play,random_play,playlocal,play_music_list,stop"></input>
|
||||
|
||||
<label for="exclude_dirs">忽略目录(逗号分割):</label>
|
||||
<input id="exclude_dirs" type="text" value="@eaDir"></input>
|
||||
|
||||
<label for="music_path_depth">目录深度:</label>
|
||||
<input id="music_path_depth" type="number" value="10"></input>
|
||||
|
||||
<label for="search_prefix">XIAOMUSIC_SEARCH(歌曲下载方式):</label>
|
||||
<select id="search_prefix">
|
||||
<option value="bilisearch:">bilisearch:</option>
|
||||
<option value="ytsearch:">ytsearch:</option>
|
||||
</select>
|
||||
|
||||
<label for="proxy">XIAOMUSIC_PROXY(ytsearch需要):</label>
|
||||
<input id="proxy" type="text" placeholder="http://192.168.2.5:8080"></input>
|
||||
|
||||
<label for="remove_id3tag">去除MP3 ID3v2和填充:</label>
|
||||
<select id="remove_id3tag">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="convert_to_mp3">转换为MP3:</label>
|
||||
<select id="convert_to_mp3">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="miio_tts_command">MiIO sst 指令:</label>
|
||||
<input id="miio_tts_command" type="text" placeholder="如:5 或者 5-3"></input>
|
||||
|
||||
<label for="disable_httpauth">关闭控制台密码验证:</label>
|
||||
<select id="disable_httpauth">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
<label for="httpauth_username">控制台账户:</label>
|
||||
<input id="httpauth_username" type="text" value=""></input>
|
||||
<label for="httpauth_password">控制台密码:</label>
|
||||
<input id="httpauth_password" type="password" value=""></input>
|
||||
|
||||
<label for="disable_download">关闭下载功能:</label>
|
||||
<select id="disable_download">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="use_music_audio_id">触屏版显示歌曲ID:</label>
|
||||
<input id="use_music_audio_id" type="text" value="1582971365183456177"></input>
|
||||
<label for="use_music_id">触屏版显示歌曲分段ID:</label>
|
||||
<input id="use_music_id" type="text" value="355454500"></input>
|
||||
|
||||
<label for="fuzzy_match_cutoff">模糊匹配阈值(0.1~0.9):</label>
|
||||
<input id="fuzzy_match_cutoff" type="number" value="0.6"></input>
|
||||
|
||||
<label for="enable_fuzzy_match">开启模糊搜索:</label>
|
||||
<select id="enable_fuzzy_match">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
|
||||
<label for="use_music_api">型号兼容模式:</label>
|
||||
<select id="use_music_api">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="continue_play">启用继续播放(可能导致兼容性问题):</label>
|
||||
<select id="continue_play">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="port">监听端口(修改后需要重启):</label>
|
||||
<input id="port" type="number" value="8090"></input>
|
||||
|
||||
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
|
||||
<input id="public_port" type="number" value="0"></input>
|
||||
|
||||
<label for="pull_ask_sec">获取对话记录间隔(秒):</label>
|
||||
<input id="pull_ask_sec" type="number" value="1"></input>
|
||||
|
||||
<label for="delay_sec">下一首歌延迟播放秒数:</label>
|
||||
<input id="delay_sec" type="number" value="3"></input>
|
||||
|
||||
<label for="stop_tts_msg">停止提示音:</label>
|
||||
<input id="stop_tts_msg" type="text" value="收到,再见"></input>
|
||||
<label for="keywords_playlocal">播放本地歌曲口令:</label>
|
||||
<input id="keywords_playlocal" type="text" value="播放本地歌曲,本地播放歌曲"></input>
|
||||
<label for="keywords_play">播放歌曲口令:</label>
|
||||
<input id="keywords_play" type="text" value="播放歌曲,放歌曲"></input>
|
||||
<label for="keywords_stop">停止口令:</label>
|
||||
<input id="keywords_stop" type="text" value="关机,暂停,停止,停止播放"></input>
|
||||
|
||||
<label for="enable_yt_dlp_cookies">启用yt-dlp-cookies(需要先上传yt-dlp-cookies.txt文件):</label>
|
||||
<select id="enable_yt_dlp_cookies">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="get_ask_by_mina">特殊型号获取对话记录:</label>
|
||||
<select id="get_ask_by_mina">
|
||||
<option value="true">true</option>
|
||||
<option value="false" selected>false</option>
|
||||
</select>
|
||||
|
||||
<label for="music_list_url">歌单地址:</label>
|
||||
<input id="music_list_url" type="text" value="https://gist.githubusercontent.com/hanxi/dda82d964a28f8110f8fba81c3ff8314/raw/example.json"></input>
|
||||
|
||||
<label for="music_list_json">歌单内容:<a href="https://github.com/hanxi/xiaomusic/issues/78" target="_blank">格式文档</a></label>
|
||||
<textarea id="music_list_json" type="text"></textarea>
|
||||
|
||||
<label for="crontab_json">定时任务:<a href="https://github.com/hanxi/xiaomusic/issues/182" target="_blank">格式文档</a></label>
|
||||
<textarea id="crontab_json" type="text"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="rows">
|
||||
<label for="yt_dlp_cookies_file">上传yt_dlp_cookies.txt文件:<a href="https://github.com/hanxi/xiaomusic/issues/210" target="_blank">文档</a></label>
|
||||
<input id="yt_dlp_cookies_file" name="file" type="file">
|
||||
<button id="upload_yt_dlp_cookie">上传</button>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<button onclick="location.href='/static/default/index.html';">返回首页</button>
|
||||
<button id="get_music_list">获取歌单</button>
|
||||
<button class="save-button">保存</button>
|
||||
<hr>
|
||||
|
||||
<button id="refresh_music_tag">刷新tag</button>
|
||||
<button id="clear_cache">清空缓存</button>
|
||||
<a class="button" href="/downloadlog" download="xiaomusic.txt">下载日志文件</a>
|
||||
|
||||
<hr>
|
||||
|
||||
<button onclick="location.href='/docs';">查看接口文档</button>
|
||||
<a class="button" href="./m3u.html" target="_blank">m3u文件转换</a>
|
||||
<a class="button" href="./downloadtool.html" target="_blank">歌曲下载工具</a>
|
||||
<hr>
|
||||
|
||||
<a class="button" href="./debug.html" target="_blank">调试工具</a>
|
||||
<a class="button" href="https://afdian.com/a/imhanxi" target="_blank">💰 爱发电</a>
|
||||
<a class="button" href="https://github.com/hanxi/xiaomusic" target="_blank">点个 Star ⭐</a>
|
||||
|
||||
<div class="rows">
|
||||
<img class="qrcode" src="./qrcode.png" alt="请涵曦喝奶茶🧋">
|
||||
</div>
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
187
xiaomusic/static/default/setting.js
Normal file
@@ -0,0 +1,187 @@
|
||||
$(function(){
|
||||
// 拉取版本
|
||||
$.get("/getversion", function(data, status) {
|
||||
console.log(data, status, data["version"]);
|
||||
$("#version").text(`${data.version}`);
|
||||
});
|
||||
|
||||
// 遍历所有的select元素,默认选中只有1个选项的
|
||||
const autoSelectOne = () => {
|
||||
$('select').each(function() {
|
||||
// 如果select元素仅有一个option子元素
|
||||
if ($(this).children('option').length === 1) {
|
||||
// 选中这个option
|
||||
$(this).find('option').prop('selected', true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function updateCheckbox(selector, mi_did, device_list) {
|
||||
// 清除现有的内容
|
||||
$(selector).empty();
|
||||
|
||||
// 将 mi_did 字符串通过逗号分割转换为数组,以便于判断默认选中项
|
||||
var selected_dids = mi_did.split(',');
|
||||
|
||||
$.each(device_list, function(index, device) {
|
||||
var did = device.miotDID;
|
||||
var hardware = device.hardware;
|
||||
var name = device.name;
|
||||
// 创建复选框元素
|
||||
var checkbox = $('<input>', {
|
||||
type: 'checkbox',
|
||||
id: did,
|
||||
value: `${did}`,
|
||||
class: 'custom-checkbox', // 添加样式类
|
||||
// 如果mi_did中包含了该did,则默认选中
|
||||
checked: selected_dids.indexOf(did) !== -1
|
||||
});
|
||||
|
||||
// 创建标签元素
|
||||
var label = $('<label>', {
|
||||
for: did,
|
||||
class: 'checkbox-label', // 添加样式类
|
||||
text: `【${hardware} ${did}】${name}` // 设定标签内容
|
||||
});
|
||||
|
||||
// 将复选框和标签添加到目标选择器元素中
|
||||
$(selector).append(checkbox).append(label);
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedDids(containerSelector) {
|
||||
var selectedDids = [];
|
||||
|
||||
// 仅选择给定容器中选中的复选框
|
||||
$(containerSelector + ' .custom-checkbox:checked').each(function() {
|
||||
var did = this.value;
|
||||
selectedDids.push(did);
|
||||
});
|
||||
|
||||
return selectedDids.join(',');
|
||||
}
|
||||
|
||||
// 拉取现有配置
|
||||
$.get("/getsetting?need_device_list=true", function(data, status) {
|
||||
console.log(data, status);
|
||||
updateCheckbox("#mi_did", data.mi_did, data.device_list);
|
||||
|
||||
// 初始化显示
|
||||
for (const key in data) {
|
||||
const $element = $("#" + key);
|
||||
if ($element.length) {
|
||||
if (data[key] === true) {
|
||||
$element.val('true');
|
||||
} else if (data[key] === false) {
|
||||
$element.val('false');
|
||||
} else {
|
||||
$element.val(data[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoSelectOne();
|
||||
});
|
||||
|
||||
$(".save-button").on("click", () => {
|
||||
var setting = $('#setting');
|
||||
var inputs = setting.find('input, select, textarea');
|
||||
var data = {};
|
||||
inputs.each(function() {
|
||||
var id = this.id;
|
||||
if (id) {
|
||||
data[id] = $(this).val();
|
||||
}
|
||||
});
|
||||
var did_list = getSelectedDids("#mi_did");
|
||||
data["mi_did"] = did_list;
|
||||
console.log(data)
|
||||
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/savesetting",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: (msg) => {
|
||||
alert(msg);
|
||||
location.reload();
|
||||
},
|
||||
error: (msg) => {
|
||||
alert(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#get_music_list").on("click", () => {
|
||||
var music_list_url = $("#music_list_url").val();
|
||||
console.log("music_list_url", music_list_url);
|
||||
var data = {
|
||||
url: music_list_url,
|
||||
};
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/downloadjson",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: (res) => {
|
||||
if (res.ret == "OK") {
|
||||
$("#music_list_json").val(res.content);
|
||||
} else {
|
||||
console.log(res);
|
||||
alert(res.ret);
|
||||
}
|
||||
},
|
||||
error: (res) => {
|
||||
console.log(res);
|
||||
alert(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#refresh_music_tag").on("click", () => {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/refreshmusictag",
|
||||
contentType: "application/json",
|
||||
success: (res) => {
|
||||
console.log(res);
|
||||
alert(res.ret);
|
||||
},
|
||||
error: (res) => {
|
||||
console.log(res);
|
||||
alert(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#upload_yt_dlp_cookie").on("click", () => {
|
||||
var fileInput = document.getElementById('yt_dlp_cookies_file');
|
||||
var file = fileInput.files[0]; // 获取文件对象
|
||||
if (file) {
|
||||
var formData = new FormData();
|
||||
formData.append("file", file);
|
||||
$.ajax({
|
||||
url: "/uploadytdlpcookie",
|
||||
type: "POST",
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function(res) {
|
||||
console.log(res);
|
||||
alert("上传成功");
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.log(res);
|
||||
alert("上传失败");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert("请选择一个文件");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
$("#clear_cache").on("click", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
});
|
||||
102
xiaomusic/static/default/style.css
Normal file
@@ -0,0 +1,102 @@
|
||||
.button {
|
||||
line-height: 50px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button, .button {
|
||||
margin: 10px;
|
||||
width: 100px;
|
||||
height: 50px;
|
||||
border: none;
|
||||
color: white;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border-radius: 10px;
|
||||
background-color: #008CBA;
|
||||
}
|
||||
button:active, .button:active {
|
||||
font-weight:bold;
|
||||
background-color: #007CBA;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
label {
|
||||
margin-left: 10px;
|
||||
width: 300px;
|
||||
}
|
||||
input,select {
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
textarea{
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
display: inline-block;
|
||||
margin: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: middle; /* 确保与标签垂直居中对齐 */
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-block;
|
||||
width: 260px;
|
||||
background-color: #fff;
|
||||
border: 0px solid #ccc;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
vertical-align: middle; /* 确保与复选框垂直居中对齐 */
|
||||
margin-left: 1px; /* 给复选框和标签之间一些距离,如果需要的话 */
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 10px;
|
||||
width: 150px;
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.qrcode {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
BIN
xiaomusic/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
62
xiaomusic/static/index.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小爱音箱操控面板</title>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)};
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container_wrapper">
|
||||
<div class="logo">
|
||||
<img src="/static/xiaoai.png" alt="">
|
||||
</div>
|
||||
<div class="desc">
|
||||
<h1>谁家灯火夜通明</h1>
|
||||
<p class="call">小爱同学?</p>
|
||||
<p class="answer">哎,我在</p>
|
||||
</div>
|
||||
<div class="options">
|
||||
<!-- 选择主题 /static/[theme] -->
|
||||
<a href="/static/default/index.html" class="href">默认主题</a>
|
||||
<a href="/static/pure/index.html" class="href">Pure主题</a>
|
||||
<a href="/static/xplayer/index.html" class="href">XMusicPlayer</a>
|
||||
<a href="https://afdian.com/a/imhanxi" target="_blank">爱发电</a>
|
||||
<a href="https://github.com/hanxi/xiaomusic" target="_blank">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
power by <a href="https://github.com/hanxi/xiaomusic">XiaoMusic</a>
|
||||
</footer>
|
||||
<style>
|
||||
@font-face{ font-family: "得意黑 斜体"; font-weight: 400; src: url("//at.alicdn.com/wf/webfont/603VmyqiyGMz/gJk2ny0v51vn.woff2") format("woff2"), url("//at.alicdn.com/wf/webfont/603VmyqiyGMz/e2C1wSBHH86h.woff") format("woff"); font-display: swap;} @font-face{ font-family: "阿里妈妈数黑体 Bold"; font-weight: 700; src: url("//at.alicdn.com/wf/webfont/603VmyqiyGMz/4DWYdFK3dz5J.woff2") format("woff2"), url("//at.alicdn.com/wf/webfont/603VmyqiyGMz/V7EBEKlNSdxC.woff") format("woff"); font-display: swap;} body{ background-color: rgb(47, 44, 67); height: 100%; overflow: hidden;}
|
||||
.container_wrapper{display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; height: 90vh; cursor: default;}
|
||||
h1{ font-weight: bold; color: #a2a9af; max-width: 600px; font-family: '得意黑 斜体', 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; font-size: 2.5em; border-bottom: 1px solid #a2a9af;}
|
||||
.container_wrapper .logo img{ width: 140px; height: auto; filter: drop-shadow(10px 10px 10px rgba(0, 0, 0, 0.5));}
|
||||
.desc{ text-align: center; color: #fff; margin: auto 30px; backdrop-filter: blur(5px);}
|
||||
.desc p{ font-size: 1.2em; margin: 0; padding: 0; font-family: '阿里妈妈数黑体 Bold'; font-weight: 800;}
|
||||
p.call{ letter-spacing: 0.4em; font-size: 2.2em; line-height: 1.5; font-style: normal;}
|
||||
p.answer{ letter-spacing: 0.23em; line-height: 1.5; font-style: normal; color: #a2a9af; margin-top: 10px;}
|
||||
.desc p::before, .desc p::after{ font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; font-size: 1.5em; color: #4c5870;}
|
||||
.desc p::before{ content: "“";} .desc p::after{ content: "”";}
|
||||
.options{ display: flex; flex-direction: column;}
|
||||
.options a{ color: #a2a9af; text-decoration: none; font-size: 1.1em; position: relative; display: inline; margin: 10px auto;}
|
||||
.options a::before{ content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #ebedec; transform-origin: bottom right; transform: scaleX(0); transition: transform 0.3s ease;}
|
||||
.options a:hover::before{ transform-origin: bottom left; transform: scaleX(1);} .options a:hover{ color:#ebedec;}
|
||||
footer{ display: flex; justify-content: center; color: #4c5870;} footer a{ color:inherit; text-decoration: none; margin: auto 10px;}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
xiaomusic/static/pure/assets/accordion-BDgIXkx5.gif
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
xiaomusic/static/pure/assets/classical-DtF24PuH.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
xiaomusic/static/pure/assets/guidance-BdU7g-Gp.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
41
xiaomusic/static/pure/assets/index-B19OeAC1.js
Normal file
1
xiaomusic/static/pure/assets/index-co2Wfzfa.css
Normal file
BIN
xiaomusic/static/pure/defaultcover.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
xiaomusic/static/pure/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
26
xiaomusic/static/pure/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/static/pure/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小爱音箱操控面板</title>
|
||||
<script type="module" crossorigin src="/static/pure/assets/index-B19OeAC1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/pure/assets/index-co2Wfzfa.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- 作者的统计代码 -->
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments) };
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-Z09NC1K7ZW');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
xiaomusic/static/xiaoai.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
30
xiaomusic/static/xplayer/assets/index-BhKRZfzz.js
Normal file
1
xiaomusic/static/xplayer/assets/index-DZp3LGgk.css
Normal file
BIN
xiaomusic/static/xplayer/cover.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
xiaomusic/static/xplayer/defaultcover.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
xiaomusic/static/xplayer/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
14
xiaomusic/static/xplayer/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/static/xplayer/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XMusicPlayer</title>
|
||||
<script type="module" crossorigin src="/static/xplayer/assets/index-BhKRZfzz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/xplayer/assets/index-DZp3LGgk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import difflib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import shutil
|
||||
import string
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import asdict, dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import AsyncIterator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
import mutagen
|
||||
from mutagen.asf import ASF
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.id3 import APIC, ID3, Encoding, TextFrame, TimeStampTextFrame
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.wave import WAVE
|
||||
from mutagen.wavpack import WavPack
|
||||
from opencc import OpenCC
|
||||
from PIL import Image
|
||||
from requests.utils import cookiejar_from_dict
|
||||
|
||||
from xiaomusic.const import SUPPORT_MUSIC_TYPE
|
||||
|
||||
log = logging.getLogger(__package__)
|
||||
|
||||
cc = OpenCC("t2s") # convert from Traditional Chinese to Simplified Chinese
|
||||
|
||||
|
||||
### HELP FUNCTION ###
|
||||
def parse_cookie_string(cookie_string):
|
||||
@@ -61,3 +94,763 @@ def validate_proxy(proxy_str: str) -> bool:
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# 模糊搜索
|
||||
def fuzzyfinder(user_input, collection, extra_search_index=None):
|
||||
return find_best_match(
|
||||
user_input, collection, cutoff=0.1, n=10, extra_search_index=extra_search_index
|
||||
)
|
||||
|
||||
|
||||
def traditional_to_simple(to_convert: str):
|
||||
return cc.convert(to_convert)
|
||||
|
||||
|
||||
# 关键词检测
|
||||
def keyword_detection(user_input, str_list, n):
|
||||
# 过滤包含关键字的字符串
|
||||
matched, remains = [], []
|
||||
for item in str_list:
|
||||
if user_input in item:
|
||||
matched.append(item)
|
||||
else:
|
||||
remains.append(item)
|
||||
|
||||
matched = sorted(
|
||||
matched,
|
||||
key=lambda s: difflib.SequenceMatcher(None, s, user_input).ratio(),
|
||||
reverse=True, # 降序排序,越相似的越靠前
|
||||
)
|
||||
|
||||
# 如果 n 是 -1,如果 n 大于匹配的数量,返回所有匹配的结果
|
||||
if n == -1 or n > len(matched):
|
||||
return matched, remains
|
||||
|
||||
# 选择前 n 个匹配的结果
|
||||
remains = matched[n:] + remains
|
||||
return matched[:n], remains
|
||||
|
||||
|
||||
def real_search(prompt, candidates, cutoff, n):
|
||||
matches, remains = keyword_detection(prompt, candidates, n=n)
|
||||
if len(matches) < n:
|
||||
# 如果没有准确关键词匹配,开始模糊匹配
|
||||
matches += difflib.get_close_matches(prompt, remains, n=n, cutoff=cutoff)
|
||||
return matches
|
||||
|
||||
|
||||
def find_best_match(user_input, collection, cutoff=0.6, n=1, extra_search_index=None):
|
||||
lower_collection = {
|
||||
traditional_to_simple(item.lower()): item for item in collection
|
||||
}
|
||||
user_input = traditional_to_simple(user_input.lower())
|
||||
matches = real_search(user_input, lower_collection.keys(), cutoff, n)
|
||||
cur_matched_collection = [lower_collection[match] for match in matches]
|
||||
if len(matches) >= n or extra_search_index is None:
|
||||
return cur_matched_collection[:n]
|
||||
|
||||
# 如果数量不满足,继续搜索
|
||||
lower_extra_search_index = {
|
||||
traditional_to_simple(k.lower()): v
|
||||
for k, v in extra_search_index.items()
|
||||
if v not in cur_matched_collection
|
||||
}
|
||||
matches = real_search(user_input, lower_extra_search_index.keys(), cutoff, n)
|
||||
cur_matched_collection += [lower_extra_search_index[match] for match in matches]
|
||||
return cur_matched_collection[:n]
|
||||
|
||||
|
||||
# 歌曲排序
|
||||
def custom_sort_key(s):
|
||||
# 使用正则表达式分别提取字符串的数字前缀和数字后缀
|
||||
prefix_match = re.match(r"^(\d+)", s)
|
||||
suffix_match = re.search(r"(\d+)$", s)
|
||||
|
||||
numeric_prefix = int(prefix_match.group(0)) if prefix_match else None
|
||||
numeric_suffix = int(suffix_match.group(0)) if suffix_match else None
|
||||
|
||||
if numeric_prefix is not None:
|
||||
# 如果前缀是数字,先按前缀数字排序,再按整个字符串排序
|
||||
return (0, numeric_prefix, s)
|
||||
elif numeric_suffix is not None:
|
||||
# 如果后缀是数字,先按前缀字符排序,再按后缀数字排序
|
||||
return (1, s[: suffix_match.start()], numeric_suffix)
|
||||
else:
|
||||
# 如果前缀和后缀都不是数字,按字典序排序
|
||||
return (2, s)
|
||||
|
||||
|
||||
def _get_depth_path(root, directory, depth):
|
||||
# 计算当前目录的深度
|
||||
relative_path = root[len(directory) :].strip(os.sep)
|
||||
path_parts = relative_path.split(os.sep)
|
||||
if len(path_parts) >= depth:
|
||||
return os.path.join(directory, *path_parts[:depth])
|
||||
else:
|
||||
return root
|
||||
|
||||
|
||||
def _append_files_result(result, root, joinpath, files, support_extension):
|
||||
dir_name = os.path.basename(root)
|
||||
if dir_name not in result:
|
||||
result[dir_name] = []
|
||||
for file in files:
|
||||
# 过滤隐藏文件
|
||||
if file.startswith("."):
|
||||
continue
|
||||
# 过滤文件后缀
|
||||
(name, extension) = os.path.splitext(file)
|
||||
if extension.lower() not in support_extension:
|
||||
continue
|
||||
|
||||
result[dir_name].append(os.path.join(joinpath, file))
|
||||
|
||||
|
||||
def traverse_music_directory(directory, depth, exclude_dirs, support_extension):
|
||||
result = {}
|
||||
for root, dirs, files in os.walk(directory, followlinks=True):
|
||||
# 忽略排除的目录
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
# 计算当前目录的深度
|
||||
current_depth = root[len(directory) :].count(os.sep) + 1
|
||||
if current_depth > depth:
|
||||
depth_path = _get_depth_path(root, directory, depth - 1)
|
||||
_append_files_result(result, depth_path, root, files, support_extension)
|
||||
else:
|
||||
_append_files_result(result, root, root, files, support_extension)
|
||||
return result
|
||||
|
||||
|
||||
async def downloadfile(url):
|
||||
# 清理和验证URL
|
||||
# 解析URL
|
||||
parsed_url = urlparse(url)
|
||||
# 基础验证:仅允许HTTP和HTTPS协议
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
raise Warning(
|
||||
f"Invalid URL scheme: {parsed_url.scheme}. Only HTTP and HTTPS are allowed."
|
||||
)
|
||||
# 构建目标URL
|
||||
cleaned_url = parsed_url.geturl()
|
||||
|
||||
# 使用 aiohttp 创建一个客户端会话来发起请求
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
cleaned_url, timeout=5
|
||||
) as response: # 增加超时以避免长时间挂起
|
||||
# 如果响应不是200,引发异常
|
||||
response.raise_for_status()
|
||||
# 读取响应文本
|
||||
text = await response.text()
|
||||
return text
|
||||
|
||||
|
||||
def is_mp3(url):
|
||||
mt = mimetypes.guess_type(url)
|
||||
if mt and mt[0] == "audio/mpeg":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_m4a(url):
|
||||
return url.endswith(".m4a")
|
||||
|
||||
|
||||
async def _get_web_music_duration(session, url, ffmpeg_location, start=0, end=500):
|
||||
duration = 0
|
||||
headers = {"Range": f"bytes={start}-{end}"}
|
||||
async with session.get(url, headers=headers) as response:
|
||||
array_buffer = await response.read()
|
||||
with tempfile.NamedTemporaryFile() as tmp:
|
||||
tmp.write(array_buffer)
|
||||
try:
|
||||
if is_mp3(url):
|
||||
m = mutagen.mp3.MP3(tmp)
|
||||
elif is_m4a(url):
|
||||
return get_duration_by_ffprobe(tmp, ffmpeg_location)
|
||||
else:
|
||||
m = mutagen.File(tmp)
|
||||
duration = m.info.length
|
||||
except Exception as e:
|
||||
log.error(f"Error _get_web_music_duration: {e}")
|
||||
return duration
|
||||
|
||||
|
||||
async def get_web_music_duration(url, ffmpeg_location="./ffmpeg/bin"):
|
||||
duration = 0
|
||||
try:
|
||||
parsed_url = urlparse(url)
|
||||
file_path = parsed_url.path
|
||||
_, extension = os.path.splitext(file_path)
|
||||
if extension.lower() not in SUPPORT_MUSIC_TYPE:
|
||||
cleaned_url = parsed_url.geturl()
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
cleaned_url,
|
||||
allow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36"
|
||||
},
|
||||
) as response:
|
||||
url = str(response.url)
|
||||
# 设置总超时时间为3秒
|
||||
timeout = aiohttp.ClientTimeout(total=3)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
duration = await _get_web_music_duration(
|
||||
session, url, ffmpeg_location, start=0, end=500
|
||||
)
|
||||
if duration <= 0:
|
||||
duration = await _get_web_music_duration(
|
||||
session, url, ffmpeg_location, start=0, end=3000
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error get_web_music_duration: {e}")
|
||||
return duration, url
|
||||
|
||||
|
||||
# 获取文件播放时长
|
||||
async def get_local_music_duration(filename, ffmpeg_location="./ffmpeg/bin"):
|
||||
loop = asyncio.get_event_loop()
|
||||
duration = 0
|
||||
try:
|
||||
if is_mp3(filename):
|
||||
m = await loop.run_in_executor(None, mutagen.mp3.MP3, filename)
|
||||
elif is_m4a(filename):
|
||||
duration = get_duration_by_ffprobe(filename, ffmpeg_location)
|
||||
return duration
|
||||
else:
|
||||
m = await loop.run_in_executor(None, mutagen.File, filename)
|
||||
duration = m.info.length
|
||||
except Exception as e:
|
||||
log.error(f"Error getting local music {filename} duration: {e}")
|
||||
return duration
|
||||
|
||||
|
||||
def get_duration_by_ffprobe(file_path, ffmpeg_location):
|
||||
# 使用 ffprobe 获取文件的元数据,并以 JSON 格式输出
|
||||
result = subprocess.run(
|
||||
[
|
||||
os.path.join(ffmpeg_location, "ffprobe"),
|
||||
"-v",
|
||||
"error", # 只输出错误信息,避免混杂在其他输出中
|
||||
"-show_entries",
|
||||
"format=duration", # 仅显示时长
|
||||
"-of",
|
||||
"json", # 以 JSON 格式输出
|
||||
file_path,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# 解析 JSON 输出
|
||||
ffprobe_output = json.loads(result.stdout)
|
||||
|
||||
# 获取时长
|
||||
duration = float(ffprobe_output["format"]["duration"])
|
||||
|
||||
return duration
|
||||
|
||||
|
||||
def get_random(length):
|
||||
return "".join(random.sample(string.ascii_letters + string.digits, length))
|
||||
|
||||
|
||||
# 深拷贝把敏感数据设置位*
|
||||
def deepcopy_data_no_sensitive_info(data, fields_to_anonymize=None):
|
||||
if fields_to_anonymize is None:
|
||||
fields_to_anonymize = [
|
||||
"account",
|
||||
"password",
|
||||
"httpauth_username",
|
||||
"httpauth_password",
|
||||
]
|
||||
|
||||
copy_data = copy.deepcopy(data)
|
||||
|
||||
# 检查copy_data是否是字典或具有属性的对象
|
||||
if isinstance(copy_data, dict):
|
||||
# 对字典进行处理
|
||||
for field in fields_to_anonymize:
|
||||
if field in copy_data:
|
||||
copy_data[field] = "******"
|
||||
else:
|
||||
# 对对象进行处理
|
||||
for field in fields_to_anonymize:
|
||||
if hasattr(copy_data, field):
|
||||
setattr(copy_data, field, "******")
|
||||
|
||||
return copy_data
|
||||
|
||||
|
||||
# k1:v1,k2:v2
|
||||
def parse_str_to_dict(s, d1=",", d2=":"):
|
||||
# 初始化一个空字典
|
||||
result = {}
|
||||
parts = s.split(d1)
|
||||
|
||||
for part in parts:
|
||||
# 根据冒号切割
|
||||
subparts = part.split(d2)
|
||||
if len(subparts) == 2: # 防止数据不是成对出现
|
||||
k, v = subparts
|
||||
result[k] = v
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# remove mp3 file id3 tag and padding to reduce delay
|
||||
def no_padding(info):
|
||||
# this will remove all padding
|
||||
return 0
|
||||
|
||||
|
||||
def get_temp_dir(music_path: str):
|
||||
# 指定临时文件的目录为 music_path 目录下的 tmp 文件夹
|
||||
temp_dir = os.path.join(music_path, "tmp")
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir) # 确保目录存在
|
||||
return temp_dir
|
||||
|
||||
|
||||
def remove_id3_tags(input_file: str, config) -> str:
|
||||
music_path = config.music_path
|
||||
temp_dir = get_temp_dir(music_path)
|
||||
|
||||
# 构造新文件的路径
|
||||
out_file_name = os.path.splitext(os.path.basename(input_file))[0]
|
||||
out_file_path = os.path.join(temp_dir, f"{out_file_name}.mp3")
|
||||
relative_path = os.path.relpath(out_file_path, music_path)
|
||||
|
||||
# 路径相同的情况
|
||||
input_absolute_path = os.path.abspath(input_file)
|
||||
output_absolute_path = os.path.abspath(out_file_path)
|
||||
if input_absolute_path == output_absolute_path:
|
||||
log.info(f"File {input_file} = {out_file_path} . Skipping remove_id3_tags.")
|
||||
return None
|
||||
|
||||
# 检查目标文件是否存在
|
||||
if os.path.exists(out_file_path):
|
||||
log.info(f"File {out_file_path} already exists. Skipping remove_id3_tags.")
|
||||
return relative_path
|
||||
|
||||
audio = MP3(input_file, ID3=ID3)
|
||||
|
||||
# 检查是否存在ID3 v2.3或v2.4标签
|
||||
if audio.tags and (
|
||||
audio.tags.version == (2, 3, 0) or audio.tags.version == (2, 4, 0)
|
||||
):
|
||||
# 拷贝文件
|
||||
shutil.copy(input_file, out_file_path)
|
||||
outaudio = MP3(out_file_path, ID3=ID3)
|
||||
# 删除ID3标签
|
||||
outaudio.delete()
|
||||
# 保存修改后的文件
|
||||
outaudio.save(padding=no_padding)
|
||||
log.info(f"File {out_file_path} remove_id3_tags ok.")
|
||||
return relative_path
|
||||
|
||||
return relative_path
|
||||
|
||||
|
||||
def convert_file_to_mp3(input_file: str, config) -> str:
|
||||
music_path = config.music_path
|
||||
temp_dir = get_temp_dir(music_path)
|
||||
|
||||
out_file_name = os.path.splitext(os.path.basename(input_file))[0]
|
||||
out_file_path = os.path.join(temp_dir, f"{out_file_name}.mp3")
|
||||
relative_path = os.path.relpath(out_file_path, music_path)
|
||||
|
||||
# 路径相同的情况
|
||||
input_absolute_path = os.path.abspath(input_file)
|
||||
output_absolute_path = os.path.abspath(out_file_path)
|
||||
if input_absolute_path == output_absolute_path:
|
||||
log.info(f"File {input_file} = {out_file_path} . Skipping convert_file_to_mp3.")
|
||||
return None
|
||||
|
||||
absolute_music_path = os.path.abspath(music_path)
|
||||
if not input_absolute_path.startswith(absolute_music_path):
|
||||
log.error(f"Invalid input file path: {input_file}")
|
||||
return None
|
||||
|
||||
# 检查目标文件是否存在
|
||||
if os.path.exists(out_file_path):
|
||||
log.info(f"File {out_file_path} already exists. Skipping convert_file_to_mp3.")
|
||||
return relative_path
|
||||
|
||||
command = [
|
||||
os.path.join(config.ffmpeg_location, "ffmpeg"),
|
||||
"-i",
|
||||
input_absolute_path,
|
||||
"-f",
|
||||
"mp3",
|
||||
"-vn",
|
||||
"-y",
|
||||
out_file_path,
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.exception(f"Error during conversion: {e}")
|
||||
return None
|
||||
|
||||
log.info(f"File {input_file} to {out_file_path} convert_file_to_mp3 ok.")
|
||||
return relative_path
|
||||
|
||||
|
||||
chinese_to_arabic = {
|
||||
"零": 0,
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
"十": 10,
|
||||
"百": 100,
|
||||
"千": 1000,
|
||||
"万": 10000,
|
||||
"亿": 100000000,
|
||||
}
|
||||
|
||||
|
||||
def chinese_to_number(chinese):
|
||||
result = 0
|
||||
unit = 1
|
||||
num = 0
|
||||
for char in reversed(chinese):
|
||||
if char in chinese_to_arabic:
|
||||
val = chinese_to_arabic[char]
|
||||
if val >= 10:
|
||||
if val > unit:
|
||||
unit = val
|
||||
else:
|
||||
unit *= val
|
||||
else:
|
||||
num += val * unit
|
||||
result += num
|
||||
num = 0
|
||||
return result
|
||||
|
||||
|
||||
def list2str(li, verbose=False):
|
||||
if len(li) > 5 and not verbose:
|
||||
return f"{li[:2]} ... {li[-2:]} with len: {len(li)}"
|
||||
else:
|
||||
return f"{li}"
|
||||
|
||||
|
||||
async def get_latest_version(package_name: str) -> str:
|
||||
url = f"https://pypi.org/pypi/{package_name}/json"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data["info"]["version"]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
title: str = ""
|
||||
artist: str = ""
|
||||
album: str = ""
|
||||
year: str = ""
|
||||
genre: str = ""
|
||||
picture: str = ""
|
||||
lyrics: str = ""
|
||||
|
||||
|
||||
def _get_alltag_value(tags, k):
|
||||
v = tags.getall(k)
|
||||
if len(v) > 0:
|
||||
return _to_utf8(v[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _get_tag_value(tags, k):
|
||||
if k not in tags:
|
||||
return ""
|
||||
v = tags[k]
|
||||
return _to_utf8(v)
|
||||
|
||||
|
||||
def _to_utf8(v):
|
||||
if isinstance(v, TextFrame) and not isinstance(v, TimeStampTextFrame):
|
||||
old_ts = "".join(v.text)
|
||||
if v.encoding == Encoding.LATIN1:
|
||||
bs = old_ts.encode("latin1")
|
||||
ts = bs.decode("GBK", errors="ignore")
|
||||
return ts
|
||||
return old_ts
|
||||
elif isinstance(v, list):
|
||||
return "".join(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
def _save_picture(picture_data, save_root, file_path):
|
||||
# 计算文件名的哈希值
|
||||
file_hash = hashlib.md5(file_path.encode("utf-8")).hexdigest()
|
||||
# 创建目录结构
|
||||
dir_path = os.path.join(save_root, file_hash[-6:])
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
# 保存图片
|
||||
filename = os.path.basename(file_path)
|
||||
(name, _) = os.path.splitext(filename)
|
||||
picture_path = os.path.join(dir_path, f"{name}.jpg")
|
||||
|
||||
try:
|
||||
_resize_save_image(picture_data, picture_path)
|
||||
except Exception as e:
|
||||
log.exception(f"Error _resize_save_image: {e}")
|
||||
return picture_path
|
||||
|
||||
|
||||
def _resize_save_image(image_bytes, save_path, max_size=300):
|
||||
# 将 bytes 转换为 PIL Image 对象
|
||||
image = Image.open(io.BytesIO(image_bytes))
|
||||
image = image.convert("RGB")
|
||||
|
||||
# 获取原始尺寸
|
||||
original_width, original_height = image.size
|
||||
|
||||
# 如果图片的宽度和高度都小于 max_size,则直接保存原始图片
|
||||
if original_width <= max_size and original_height <= max_size:
|
||||
image.save(save_path, format="JPEG")
|
||||
return
|
||||
|
||||
# 计算缩放比例,保持等比缩放
|
||||
scaling_factor = min(max_size / original_width, max_size / original_height)
|
||||
|
||||
# 计算新的尺寸
|
||||
new_width = int(original_width * scaling_factor)
|
||||
new_height = int(original_height * scaling_factor)
|
||||
|
||||
resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
resized_image.save(save_path, format="JPEG")
|
||||
return save_path
|
||||
|
||||
|
||||
def extract_audio_metadata(file_path, save_root):
|
||||
audio = mutagen.File(file_path)
|
||||
metadata = Metadata()
|
||||
tags = audio.tags
|
||||
if tags is None:
|
||||
return asdict(metadata)
|
||||
|
||||
if isinstance(audio, MP3):
|
||||
metadata.title = _get_tag_value(tags, "TIT2")
|
||||
metadata.artist = _get_tag_value(tags, "TPE1")
|
||||
metadata.album = _get_tag_value(tags, "TALB")
|
||||
metadata.year = _get_tag_value(tags, "TDRC")
|
||||
metadata.genre = _get_tag_value(tags, "TCON")
|
||||
metadata.lyrics = _get_alltag_value(tags, "USLT")
|
||||
for tag in tags.values():
|
||||
if isinstance(tag, APIC):
|
||||
metadata.picture = _save_picture(tag.data, save_root, file_path)
|
||||
break
|
||||
|
||||
elif isinstance(audio, FLAC):
|
||||
metadata.title = _get_tag_value(tags, "TITLE")
|
||||
metadata.artist = _get_tag_value(tags, "ARTIST")
|
||||
metadata.album = _get_tag_value(tags, "ALBUM")
|
||||
metadata.year = _get_tag_value(tags, "DATE")
|
||||
metadata.genre = _get_tag_value(tags, "GENRE")
|
||||
if audio.pictures:
|
||||
metadata.picture = _save_picture(
|
||||
audio.pictures[0].data, save_root, file_path
|
||||
)
|
||||
if "lyrics" in audio:
|
||||
metadata.lyrics = audio["lyrics"][0]
|
||||
|
||||
elif isinstance(audio, MP4):
|
||||
metadata.title = _get_tag_value(tags, "\xa9nam")
|
||||
metadata.artist = _get_tag_value(tags, "\xa9ART")
|
||||
metadata.album = _get_tag_value(tags, "\xa9alb")
|
||||
metadata.year = _get_tag_value(tags, "\xa9day")
|
||||
metadata.genre = _get_tag_value(tags, "\xa9gen")
|
||||
if "covr" in tags:
|
||||
metadata.picture = _save_picture(tags["covr"][0], save_root, file_path)
|
||||
|
||||
elif isinstance(audio, OggVorbis):
|
||||
metadata.title = _get_tag_value(tags, "TITLE")
|
||||
metadata.artist = _get_tag_value(tags, "ARTIST")
|
||||
metadata.album = _get_tag_value(tags, "ALBUM")
|
||||
metadata.year = _get_tag_value(tags, "DATE")
|
||||
metadata.genre = _get_tag_value(tags, "GENRE")
|
||||
if "metadata_block_picture" in tags:
|
||||
picture = json.loads(base64.b64decode(tags["metadata_block_picture"][0]))
|
||||
metadata.picture = _save_picture(
|
||||
base64.b64decode(picture["data"]), save_root, file_path
|
||||
)
|
||||
|
||||
elif isinstance(audio, ASF):
|
||||
metadata.title = _get_tag_value(tags, "Title")
|
||||
metadata.artist = _get_tag_value(tags, "Author")
|
||||
metadata.album = _get_tag_value(tags, "WM/AlbumTitle")
|
||||
metadata.year = _get_tag_value(tags, "WM/Year")
|
||||
metadata.genre = _get_tag_value(tags, "WM/Genre")
|
||||
if "WM/Picture" in tags:
|
||||
metadata.picture = _save_picture(
|
||||
tags["WM/Picture"][0].value, save_root, file_path
|
||||
)
|
||||
|
||||
elif isinstance(audio, WavPack):
|
||||
metadata.title = _get_tag_value(tags, "Title")
|
||||
metadata.artist = _get_tag_value(tags, "Artist")
|
||||
metadata.album = _get_tag_value(tags, "Album")
|
||||
metadata.year = _get_tag_value(tags, "Year")
|
||||
metadata.genre = _get_tag_value(tags, "Genre")
|
||||
if audio.pictures:
|
||||
metadata.picture = _save_picture(
|
||||
audio.pictures[0].data, save_root, file_path
|
||||
)
|
||||
|
||||
elif isinstance(audio, WAVE):
|
||||
metadata.title = _get_tag_value(tags, "Title")
|
||||
metadata.artist = _get_tag_value(tags, "Artist")
|
||||
|
||||
return asdict(metadata)
|
||||
|
||||
|
||||
# 下载播放列表
|
||||
async def download_playlist(config, url, dirname):
|
||||
title = f"{dirname}/%(title)s.%(ext)s"
|
||||
sbp_args = (
|
||||
"yt-dlp",
|
||||
"--yes-playlist",
|
||||
"-x",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--paths",
|
||||
config.download_path,
|
||||
"-o",
|
||||
title,
|
||||
"--ffmpeg-location",
|
||||
f"{config.ffmpeg_location}",
|
||||
)
|
||||
|
||||
if config.proxy:
|
||||
sbp_args += ("--proxy", f"{config.proxy}")
|
||||
|
||||
if config.enable_yt_dlp_cookies:
|
||||
sbp_args += ("--cookies", f"{config.yt_dlp_cookies_path}")
|
||||
|
||||
sbp_args += (url,)
|
||||
|
||||
cmd = " ".join(sbp_args)
|
||||
log.info(f"download_playlist: {cmd}")
|
||||
download_proc = await asyncio.create_subprocess_exec(*sbp_args)
|
||||
return download_proc
|
||||
|
||||
|
||||
# 下载一首歌曲
|
||||
async def download_one_music(config, url, name=""):
|
||||
title = "%(title)s.%(ext)s"
|
||||
if name:
|
||||
title = f"{name}.%(ext)s"
|
||||
sbp_args = (
|
||||
"yt-dlp",
|
||||
"--no-playlist",
|
||||
"-x",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--paths",
|
||||
config.download_path,
|
||||
"-o",
|
||||
title,
|
||||
"--ffmpeg-location",
|
||||
f"{config.ffmpeg_location}",
|
||||
)
|
||||
|
||||
if config.proxy:
|
||||
sbp_args += ("--proxy", f"{config.proxy}")
|
||||
|
||||
if config.enable_yt_dlp_cookies:
|
||||
sbp_args += ("--cookies", f"{config.yt_dlp_cookies_path}")
|
||||
|
||||
sbp_args += (url,)
|
||||
|
||||
cmd = " ".join(sbp_args)
|
||||
log.info(f"download_one_music: {cmd}")
|
||||
download_proc = await asyncio.create_subprocess_exec(*sbp_args)
|
||||
return download_proc
|
||||
|
||||
|
||||
def _longest_common_prefix(file_names):
|
||||
if not file_names:
|
||||
return ""
|
||||
|
||||
# 将第一个文件名作为初始前缀
|
||||
prefix = file_names[0]
|
||||
|
||||
for file_name in file_names[1:]:
|
||||
while not file_name.startswith(prefix):
|
||||
# 如果当前文件名不以prefix开头,则缩短prefix
|
||||
prefix = prefix[:-1]
|
||||
if not prefix:
|
||||
return ""
|
||||
|
||||
return prefix
|
||||
|
||||
|
||||
# 移除目录下文件名前缀相同的
|
||||
def remove_common_prefix(directory):
|
||||
files = os.listdir(directory)
|
||||
|
||||
# 获取所有文件的前缀
|
||||
common_prefix = _longest_common_prefix(files)
|
||||
|
||||
log.info(f'Common prefix identified: "{common_prefix}"')
|
||||
|
||||
for filename in files:
|
||||
if filename == common_prefix:
|
||||
continue
|
||||
# 检查文件名是否以共同前缀开头
|
||||
if filename.startswith(common_prefix):
|
||||
# 构造新的文件名
|
||||
new_filename = filename[len(common_prefix) :]
|
||||
# 生成完整的文件路径
|
||||
old_file_path = os.path.join(directory, filename)
|
||||
new_file_path = os.path.join(directory, new_filename)
|
||||
|
||||
# 重命名文件
|
||||
os.rename(old_file_path, new_file_path)
|
||||
log.debug(f'Renamed: "{filename}" to "{new_filename}"')
|
||||
|
||||
|
||||
def try_add_access_control_param(config, url):
|
||||
if config.disable_httpauth:
|
||||
return url
|
||||
|
||||
url_parts = urllib.parse.urlparse(url)
|
||||
file_path = urllib.parse.unquote(url_parts.path)
|
||||
correct_code = hashlib.sha256(
|
||||
(file_path + config.httpauth_username + config.httpauth_password).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
log.debug(f"rewrite url: [{file_path}, {correct_code}]")
|
||||
|
||||
# make new url
|
||||
parsed_get_args = dict(urllib.parse.parse_qsl(url_parts.query))
|
||||
parsed_get_args.update({"code": correct_code})
|
||||
encoded_get_args = urllib.parse.urlencode(parsed_get_args, doseq=True)
|
||||
new_url = urllib.parse.ParseResult(
|
||||
url_parts.scheme,
|
||||
url_parts.netloc,
|
||||
url_parts.path,
|
||||
url_parts.params,
|
||||
encoded_get_args,
|
||||
url_parts.fragment,
|
||||
).geturl()
|
||||
|
||||
return new_url
|
||||
|
||||