mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-06 12:51:08 -08:00
Compare commits
709 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aacbcc35b | ||
|
|
5976ab43b2 | ||
|
|
99c67a4bc0 | ||
|
|
34851fd3e4 | ||
|
|
e74b5977bb | ||
|
|
0650f45fba | ||
|
|
0c8f2a70ba | ||
|
|
ce7cd98783 | ||
|
|
ec14c40c77 | ||
|
|
6a6e03c744 | ||
|
|
26de1a0fb4 | ||
|
|
e49fb4898c | ||
|
|
e2407d4948 | ||
|
|
cd16ab50e3 | ||
|
|
b53a7d9b03 | ||
|
|
2b9fdb99b1 | ||
|
|
9c6e1877ed | ||
|
|
73bb77fe46 | ||
|
|
4cdc5bfd34 | ||
|
|
ca491d95a0 | ||
|
|
1003f75db3 | ||
|
|
5821c4ca97 | ||
|
|
de774a58d2 | ||
|
|
ee25cbba10 | ||
|
|
278a771f64 | ||
|
|
0d8c287e2f | ||
|
|
74308dfdc5 | ||
|
|
7ca1b8572e | ||
|
|
54aed9e5a0 | ||
|
|
4511d14e8b | ||
|
|
bff684e8cb | ||
|
|
cfc83450c8 | ||
|
|
04a6a425b7 | ||
|
|
088d232bfd | ||
|
|
03fd8c0bf8 | ||
|
|
17f1744025 | ||
|
|
9a5f3d46be | ||
|
|
66eb854da5 | ||
|
|
ae62adf233 | ||
|
|
55a7c7facf | ||
|
|
2340c34d02 | ||
|
|
40b29ba6e5 | ||
|
|
5dc768f7e8 | ||
|
|
b343bfb645 | ||
|
|
37773265ce | ||
|
|
70ef1bf633 | ||
|
|
bee97acd35 | ||
|
|
fb61fd17f1 | ||
|
|
98fff7d00f | ||
|
|
3cc9ae50b6 | ||
|
|
26f7de172a | ||
|
|
673b6280e4 | ||
|
|
7943dcc3db | ||
|
|
49ee1f9bbd | ||
|
|
fd80149e74 | ||
|
|
7c11616bea | ||
|
|
b9130018ca | ||
|
|
70ade13017 | ||
|
|
071c0daf4f | ||
|
|
ed136fc8a0 | ||
|
|
3a29127366 | ||
|
|
b7c938fec4 | ||
|
|
c6aada6139 | ||
|
|
e4c4203364 | ||
|
|
52cd4a8d85 | ||
|
|
b9b0d49530 | ||
|
|
ba4df96dc8 | ||
|
|
335ba86367 | ||
|
|
92c18f850f | ||
|
|
70763807de | ||
|
|
51438c8864 | ||
|
|
03426bd0da | ||
|
|
ccdb0346eb | ||
|
|
6fba74b3ca | ||
|
|
b436f23f65 | ||
|
|
cd7b70dd6b | ||
|
|
ac9b000ce8 | ||
|
|
84a3b6185b | ||
|
|
7f52d8cb39 | ||
|
|
87372e41be | ||
|
|
9cfbc0bdcf | ||
|
|
af6e64ee0c | ||
|
|
4a2f272e14 | ||
|
|
a2e2ae8dd3 | ||
|
|
5ce9bbaa0d | ||
|
|
25812b6562 | ||
|
|
ee52b945ea | ||
|
|
be14e6a135 | ||
|
|
9402e7a2b6 | ||
|
|
590d6a1851 | ||
|
|
8186fe9991 | ||
|
|
4d2831eee1 | ||
|
|
ea918909b9 | ||
|
|
93c0f2ab83 | ||
|
|
985e7fee18 | ||
|
|
7bd7ddecae | ||
|
|
9afb9a9a32 | ||
|
|
40065478cc | ||
|
|
65aa8fcb4e | ||
|
|
2717d0b012 | ||
|
|
9f0cf5f8dc | ||
|
|
ef4a850d75 | ||
|
|
d8804c711e | ||
|
|
adf75f65b2 | ||
|
|
6e7e75b514 | ||
|
|
9515559afb | ||
|
|
e8849940e1 | ||
|
|
007954802f | ||
|
|
b874bef2d5 | ||
|
|
6c1bcebd99 | ||
|
|
fe5e8c641d | ||
|
|
45a4913ead | ||
|
|
03f0f40c9a | ||
|
|
af616e0047 | ||
|
|
6000ed2a84 | ||
|
|
5a869060d8 | ||
|
|
58618bd82d | ||
|
|
84570c5595 | ||
|
|
18b4071ad9 | ||
|
|
fd052c87de | ||
|
|
ee959b3428 | ||
|
|
ad6bdad594 | ||
|
|
316832e771 | ||
|
|
9edeeb5ca4 | ||
|
|
7eb6054d5c | ||
|
|
5b06039cef | ||
|
|
abe36296c1 | ||
|
|
dcbf0df1a0 | ||
|
|
c2acbcdb68 | ||
|
|
96233b14ff | ||
|
|
a8f2579f82 | ||
|
|
5ed9700c5c | ||
|
|
fd74fbe2ef | ||
|
|
19426019a2 | ||
|
|
276c8d48d9 | ||
|
|
99809f3fd3 | ||
|
|
f79c8540c3 | ||
|
|
e602a6fbc4 | ||
|
|
8abfaed7bf | ||
|
|
15b920698a | ||
|
|
44cf9c3da7 | ||
|
|
460d3c7d94 | ||
|
|
5f030a5d9e | ||
|
|
8091e23196 | ||
|
|
e641a48156 | ||
|
|
c59babc30d | ||
|
|
494104ee19 | ||
|
|
159136cfb1 | ||
|
|
7e211f109e | ||
|
|
48e906e464 | ||
|
|
98c2fef8cd | ||
|
|
7088b8ce18 | ||
|
|
6cfc766db3 | ||
|
|
89ff453778 | ||
|
|
1c95d45be4 | ||
|
|
75e67c22d2 | ||
|
|
1a1d8cc8f4 | ||
|
|
3ea37c4079 | ||
|
|
b18e419831 | ||
|
|
fe06c8e0f1 | ||
|
|
759276237f | ||
|
|
d475dda41c | ||
|
|
ad499657e0 | ||
|
|
dbf96afea7 | ||
|
|
001a63d3df | ||
|
|
1207426a96 | ||
|
|
82ca5f32b1 | ||
|
|
2924fcd077 | ||
|
|
9fc66db248 | ||
|
|
f4e73c3335 | ||
|
|
5246a2fc4b | ||
|
|
4bbfe221f2 | ||
|
|
6017833605 | ||
|
|
f2538f5341 | ||
|
|
fec09e9b74 | ||
|
|
67a174158d | ||
|
|
ae3a59d116 | ||
|
|
fd59d64b76 | ||
|
|
b1ac4a6558 | ||
|
|
8e9aeb660f | ||
|
|
4f401aa91c | ||
|
|
b8733eccbd | ||
|
|
3617465f64 | ||
|
|
4c9ecafb9b | ||
|
|
e87e4e5639 | ||
|
|
16fa39397d | ||
|
|
24ffc4ee53 | ||
|
|
3e0be026eb | ||
|
|
a04643d36a | ||
|
|
5392d4f25a | ||
|
|
63e107ba53 | ||
|
|
9ba3f88813 | ||
|
|
5da5dc5dcc | ||
|
|
3c42f660ce | ||
|
|
4349b9fc22 | ||
|
|
048d008ca1 | ||
|
|
17636d766a | ||
|
|
367520450d | ||
|
|
d6f773f41f | ||
|
|
f76350bc5b | ||
|
|
d299355d90 | ||
|
|
4aa9fa9253 | ||
|
|
fcb16a574e | ||
|
|
8b52a1ef27 | ||
|
|
574a739cb6 | ||
|
|
f27a98aaa6 | ||
|
|
a266a7100f | ||
|
|
d1e07930f9 | ||
|
|
bc7936d9cc | ||
|
|
19c6656cdf | ||
|
|
06506fb47f | ||
|
|
29480c64cd | ||
|
|
474da2f1fd | ||
|
|
abac604ccd | ||
|
|
48f46cdf3d | ||
|
|
9cafcde9e1 | ||
|
|
e908c793c6 | ||
|
|
0fd69d03dd | ||
|
|
3a9be3f699 | ||
|
|
d3f08ea9c4 | ||
|
|
9efe9f9949 | ||
|
|
83933f7a63 | ||
|
|
afe1cb68f6 | ||
|
|
a6ddb10734 | ||
|
|
f678fa13f0 | ||
|
|
2067467134 | ||
|
|
d78b62fcee | ||
|
|
6c30cf808b | ||
|
|
6e9babf270 | ||
|
|
aa50ab62b5 | ||
|
|
88975e59c0 | ||
|
|
64c427fe41 | ||
|
|
987ae57e33 | ||
|
|
ac36e24a32 | ||
|
|
04d877a72e | ||
|
|
43174db8e4 | ||
|
|
3092ef0887 | ||
|
|
5e45fba66d | ||
|
|
65e4726f82 | ||
|
|
384d326fa8 | ||
|
|
60c583d115 | ||
|
|
f716f9687a | ||
|
|
db1006a6b2 | ||
|
|
9163b1394d | ||
|
|
0ce27f8e50 | ||
|
|
0e6aeeea18 | ||
|
|
452c2cf764 | ||
|
|
a1de0548f4 | ||
|
|
f60cdea2e1 | ||
|
|
b67284cfeb | ||
|
|
17161f5f78 | ||
|
|
c0d87c4351 | ||
|
|
725fe4875d | ||
|
|
ac3c6801d7 | ||
|
|
27b1f3f792 | ||
|
|
49cdd440df | ||
|
|
490f8b0e8b | ||
|
|
5dde02570a | ||
|
|
e3deb28d26 | ||
|
|
1a85b2f216 | ||
|
|
0639a3c949 | ||
|
|
bdbf0821c5 | ||
|
|
5e81c44312 | ||
|
|
41ed56f395 | ||
|
|
26f9c3b8de | ||
|
|
ecdd1b5f20 | ||
|
|
b6dd965e49 | ||
|
|
273dd56680 | ||
|
|
be4cc58e47 | ||
|
|
c882691412 | ||
|
|
f4c4c874df | ||
|
|
f8992d46dd | ||
|
|
222c50b4b2 | ||
|
|
064401f8e8 | ||
|
|
a079f9919c | ||
|
|
a88df7f3ef | ||
|
|
d1dfddf290 | ||
|
|
e8491e3723 | ||
|
|
2f21e7139b | ||
|
|
f5c831077d | ||
|
|
badd10bf97 | ||
|
|
42bd4963b8 | ||
|
|
f08ff7155c | ||
|
|
e487435d7e | ||
|
|
54f7327ed7 | ||
|
|
ba620bae96 | ||
|
|
48eac48738 | ||
|
|
194b8ca2df | ||
|
|
96c2d4976c | ||
|
|
c5034a5829 | ||
|
|
7c91288e6e | ||
|
|
de2ba342ad | ||
|
|
b847e02fe0 | ||
|
|
f02f92b80b | ||
|
|
a2da6974fa | ||
|
|
be1babbedc | ||
|
|
5c804f7aa6 | ||
|
|
723a7ab24f | ||
|
|
18a9b07144 | ||
|
|
d279cc70b9 | ||
|
|
27d71cbb23 | ||
|
|
615b420c74 | ||
|
|
f9c2b6e939 | ||
|
|
85368393fc | ||
|
|
b9636c94d3 | ||
|
|
4920ee508a | ||
|
|
fd448ad701 | ||
|
|
b223a34879 | ||
|
|
d5e1e60266 | ||
|
|
783b63219f | ||
|
|
317fee916b | ||
|
|
870bb24e1b | ||
|
|
f51ceaacd7 | ||
|
|
cdad70e40d | ||
|
|
0737c5c14b | ||
|
|
32f4d9271f | ||
|
|
355f10dd9e | ||
|
|
2f2ffc0a84 | ||
|
|
e35683e90a | ||
|
|
2bd02c7e99 | ||
|
|
5a50e79216 | ||
|
|
ec78c81381 | ||
|
|
f042e5042b | ||
|
|
428bbb20bd | ||
|
|
3af31a2dfd | ||
|
|
759889acd4 | ||
|
|
d106bf7c5d | ||
|
|
00ff89d14f | ||
|
|
76460b6c54 | ||
|
|
e58fd33fe0 | ||
|
|
931f9f10f8 | ||
|
|
0e9dbd2c6b | ||
|
|
3bdfa27e1c | ||
|
|
f46f09ffdf | ||
|
|
d309c04214 | ||
|
|
b19a323d15 | ||
|
|
ff94edfd05 | ||
|
|
59e1a82646 | ||
|
|
2e902fa4e7 | ||
|
|
d2e17af7a9 | ||
|
|
f1fa40c419 | ||
|
|
8bbde97403 | ||
|
|
a2b7d71eb2 | ||
|
|
67b4f0ea38 | ||
|
|
a6d5d5f37c | ||
|
|
67a066f16e | ||
|
|
e6297619d4 | ||
|
|
8f514858f2 | ||
|
|
eb9cffbd7a | ||
|
|
c5f9c37d3a | ||
|
|
44fd65ebab | ||
|
|
e919980ff7 | ||
|
|
6887c6ff10 | ||
|
|
b394de0b23 | ||
|
|
71003049d6 | ||
|
|
6f69b785d8 | ||
|
|
6756540fd1 | ||
|
|
e6b9df25dd | ||
|
|
f40dd2363a | ||
|
|
61a525ff94 | ||
|
|
27d671f89d | ||
|
|
58edb0427f | ||
|
|
7abcdc8f7c | ||
|
|
ca4ca0d476 | ||
|
|
5a337b1c97 | ||
|
|
6c94dd22fc | ||
|
|
8c7e1e201f | ||
|
|
98e41e1eb5 | ||
|
|
bd0e7db73c | ||
|
|
fb705b4ac2 | ||
|
|
93654be74f | ||
|
|
9b14a4c723 | ||
|
|
bce5acf7b5 | ||
|
|
228be7e1f7 | ||
|
|
2eb434e42a | ||
|
|
fbb3a00ab0 | ||
|
|
8341ffe8fd | ||
|
|
0822e2e92c | ||
|
|
3b9fbd0665 | ||
|
|
60b74bee18 | ||
|
|
d7dc63e003 | ||
|
|
d40edb6ff6 | ||
|
|
803712649f | ||
|
|
7bc0d33f69 | ||
|
|
5885d134df | ||
|
|
5500ec49c8 | ||
|
|
8c94380050 | ||
|
|
87a97dd0c6 | ||
|
|
80d9f732b1 | ||
|
|
051273dac9 | ||
|
|
036f448906 | ||
|
|
b5aeed9268 | ||
|
|
4257502b85 | ||
|
|
28a857520f | ||
|
|
4f9fff375c | ||
|
|
ce31f63788 | ||
|
|
9412c2491e | ||
|
|
8209adec62 | ||
|
|
39703d9eca | ||
|
|
57d16b3e18 | ||
|
|
73a99f8b96 | ||
|
|
309d7d5858 | ||
|
|
8d20e490ca | ||
|
|
3a6e005f3a | ||
|
|
bdf49bd7ce | ||
|
|
c4df2587d0 | ||
|
|
b38f66767f | ||
|
|
6c0e0ccf72 | ||
|
|
e39c992883 | ||
|
|
a1744fc9b3 | ||
|
|
3c5106c32c | ||
|
|
fd0d899f72 | ||
|
|
c753873f61 | ||
|
|
4c8ff2ae9b | ||
|
|
23274de367 | ||
|
|
2aec40ead0 | ||
|
|
172f2bb1de | ||
|
|
2f5684a93a | ||
|
|
1d40160abf | ||
|
|
af84d80137 | ||
|
|
e6412631ae | ||
|
|
978d8d45ba | ||
|
|
06575120d6 | ||
|
|
72cec28613 | ||
|
|
8023edcf3a | ||
|
|
0cb50cd506 | ||
|
|
9981b3dec8 | ||
|
|
50c048e158 | ||
|
|
c0a57c7814 | ||
|
|
bcdd88c725 | ||
|
|
d45d438663 | ||
|
|
3d12059e27 | ||
|
|
677f4690fa | ||
|
|
a79b59f727 | ||
|
|
5641c245e7 | ||
|
|
058fc285cd | ||
|
|
71cfe667c9 | ||
|
|
d9692201aa | ||
|
|
1fd4087b41 | ||
|
|
787eb0c9ca | ||
|
|
acd937f8ab | ||
|
|
52af68d13f | ||
|
|
1ff3074fad | ||
|
|
debaa2ffa6 | ||
|
|
5b6ccbe748 | ||
|
|
d6ca923951 | ||
|
|
0e9bf7f2de | ||
|
|
ccad2435b0 | ||
|
|
30fa9851dd | ||
|
|
000bae9bb7 | ||
|
|
8c2bb71e08 | ||
|
|
57393b085a | ||
|
|
5f721847d7 | ||
|
|
383cb62ede | ||
|
|
434ac947dd | ||
|
|
d0fb39cede | ||
|
|
f98ae77587 | ||
|
|
33e1b0fb6f | ||
|
|
7134702eb9 | ||
|
|
cac7586a86 | ||
|
|
0b9da27def | ||
|
|
ddbb4ca451 | ||
|
|
757393aa36 | ||
|
|
eb54d5e995 | ||
|
|
0d95a38321 | ||
|
|
8d2734db74 | ||
|
|
b3abcb958b | ||
|
|
0667749e4c | ||
|
|
57e73e6799 | ||
|
|
7d890b9719 | ||
|
|
8cbbcf458d | ||
|
|
67bc25a527 | ||
|
|
e668f9326a | ||
|
|
a02db6471f | ||
|
|
08b1f0c90c | ||
|
|
3ec8dbee8c | ||
|
|
473c11faca | ||
|
|
320e3799d3 | ||
|
|
a0f28ddf6d | ||
|
|
9512c3530a | ||
|
|
72602a0ec1 | ||
|
|
4daf6a2b07 | ||
|
|
8b37927f6a | ||
|
|
9d6f785a7f | ||
|
|
897c34d98c | ||
|
|
28c75215bd | ||
|
|
8697b27fe0 | ||
|
|
b6e05c877b | ||
|
|
d8c3ba6181 | ||
|
|
8b5c917038 | ||
|
|
856f62c245 | ||
|
|
02dfc9d71c | ||
|
|
cef0bae528 | ||
|
|
4867720ad2 | ||
|
|
8d85e30150 | ||
|
|
eb99b7e6ba | ||
|
|
089c049f26 | ||
|
|
a33e47d205 | ||
|
|
25dc35eaaf | ||
|
|
525586e955 | ||
|
|
5129219e23 | ||
|
|
7cd97c78b1 | ||
|
|
27b4422ef3 | ||
|
|
1c367c8aa1 | ||
|
|
7b6cc48b90 | ||
|
|
812d0110a7 | ||
|
|
60b05bf0ac | ||
|
|
d830cca3bc | ||
|
|
209e93b6d9 | ||
|
|
b10d9dc39a | ||
|
|
fe8cda094c | ||
|
|
33c06eab0a | ||
|
|
f3f4be7410 | ||
|
|
3915ef0fb6 | ||
|
|
20d26166dd | ||
|
|
ddca724bd8 | ||
|
|
b86c1a0479 | ||
|
|
1fa7830ddf | ||
|
|
59abafbe16 | ||
|
|
b6eebb9736 | ||
|
|
7797053102 | ||
|
|
d763445f72 | ||
|
|
7bc6b14b5f | ||
|
|
f70d2ac8af | ||
|
|
defdfc5a47 | ||
|
|
e67eeda492 | ||
|
|
a17588d02c | ||
|
|
67b59305c4 | ||
|
|
61db9aeea6 | ||
|
|
4f0768a060 | ||
|
|
21704cbbea | ||
|
|
886bc4d011 | ||
|
|
e3437e066a | ||
|
|
8f2795843a | ||
|
|
c6290592e8 | ||
|
|
050ba740b8 | ||
|
|
0b1a27b223 | ||
|
|
bafd04b788 | ||
|
|
fb5f51eea5 | ||
|
|
799e1f0681 | ||
|
|
53a2d953f8 | ||
|
|
9ce5bc3c76 | ||
|
|
dc58fc8536 | ||
|
|
1d5c3016fc | ||
|
|
8737aea746 | ||
|
|
bd03866f5e | ||
|
|
81690a8015 | ||
|
|
933112a52b | ||
|
|
eb513dfe0e | ||
|
|
3928b77506 | ||
|
|
95cb2bd78c | ||
|
|
4fa1c45eb2 | ||
|
|
b9051bc792 | ||
|
|
a590024f1c | ||
|
|
2f51936679 | ||
|
|
327c50d290 | ||
|
|
031dfbb9b5 | ||
|
|
050365302a | ||
|
|
0f248b1119 | ||
|
|
871d5cf758 | ||
|
|
320376d2e8 | ||
|
|
02e7fdff6f | ||
|
|
2c5c28f295 | ||
|
|
2d3509ccc1 | ||
|
|
30babf2d69 | ||
|
|
cfbbabf898 | ||
|
|
5ac6c45fdf | ||
|
|
a14645b563 | ||
|
|
90dbc26c46 | ||
|
|
54cc830c35 | ||
|
|
4928ff5b74 | ||
|
|
bb481fe21a | ||
|
|
0d27b8f652 | ||
|
|
bdd3aae399 | ||
|
|
af94cd7eb5 | ||
|
|
54044f9527 | ||
|
|
1e5c039ece | ||
|
|
15555759dc | ||
|
|
0ed51e05cc | ||
|
|
634ef6febf | ||
|
|
bda4b2dbe1 | ||
|
|
f015305e7c | ||
|
|
d32b7e917f | ||
|
|
3b35e80199 | ||
|
|
c65a1a2815 | ||
|
|
0b3615c9f5 | ||
|
|
3ac4e1ac71 | ||
|
|
d62f580d7a | ||
|
|
02e35b66cb | ||
|
|
7b11e0a301 | ||
|
|
aa8b91aed3 | ||
|
|
fe0fa97576 | ||
|
|
92059cd5ed | ||
|
|
ed3064e3b1 | ||
|
|
441d1e5e6c | ||
|
|
653b2cf4eb | ||
|
|
8d4b71e0c8 | ||
|
|
29cc6cad09 | ||
|
|
8119eef263 | ||
|
|
912c8674cf | ||
|
|
6b3ca236dd | ||
|
|
f1c352d4ff | ||
|
|
714533d845 | ||
|
|
56dd25df8d | ||
|
|
8248dc53df | ||
|
|
1a8a187de6 | ||
|
|
bc86be8c93 | ||
|
|
75026d4fc5 | ||
|
|
f8a5ccb8d2 | ||
|
|
719d1bd187 | ||
|
|
0dd83463c6 | ||
|
|
966301bce8 | ||
|
|
d776880306 | ||
|
|
1ee50e8a55 | ||
|
|
ae95c5ea3d | ||
|
|
d64ad5e11d | ||
|
|
d1a47c6d44 | ||
|
|
51a834a62f | ||
|
|
3a030bf6f7 | ||
|
|
eb6a6fc82c | ||
|
|
437ccd94e4 | ||
|
|
d65868cc30 | ||
|
|
8678aa6544 | ||
|
|
00e5141152 | ||
|
|
90e757dfe1 | ||
|
|
8b471b08e8 | ||
|
|
158bc5710f | ||
|
|
a0b946a13d | ||
|
|
b547b75f03 | ||
|
|
58c7427a47 | ||
|
|
6220b9c55d | ||
|
|
6b9b5c131c | ||
|
|
212f2af39c | ||
|
|
f7b2b4e0c9 | ||
|
|
a747529279 | ||
|
|
1dfdcc27ce | ||
|
|
3c03289453 | ||
|
|
06fd446a72 | ||
|
|
172d912d8b | ||
|
|
2396018607 | ||
|
|
a9be9779c5 | ||
|
|
2f76b26a99 | ||
|
|
2fe5edf810 | ||
|
|
d67ee6a779 | ||
|
|
e06ec5dbd4 | ||
|
|
c1b24ba2aa | ||
|
|
59e9cf9fd0 | ||
|
|
58761f5b96 | ||
|
|
ac959da229 | ||
|
|
bacc8c48ec | ||
|
|
905a159428 | ||
|
|
20f734cab2 | ||
|
|
7c2c644aef | ||
|
|
0efc92081a | ||
|
|
fafeee2367 | ||
|
|
e03063cd76 | ||
|
|
93b38b055f | ||
|
|
045635fb55 | ||
|
|
de7f773e9e | ||
|
|
ef6a465bd2 | ||
|
|
0c623af8a4 | ||
|
|
0589f83998 | ||
|
|
e17608afd5 | ||
|
|
b915654685 | ||
|
|
2ce9bf6c47 | ||
|
|
3c22232432 | ||
|
|
3474e9520c | ||
|
|
e9bacf4f9c | ||
|
|
ef422ed6fd | ||
|
|
d0f5366908 | ||
|
|
3557205feb | ||
|
|
ba4c41d888 | ||
|
|
1427a3193c | ||
|
|
b5cee20e56 | ||
|
|
be7f464073 | ||
|
|
c7f8f168f5 | ||
|
|
ba59fbdcb0 | ||
|
|
9f54fa4998 | ||
|
|
3c9688b32c | ||
|
|
1f046447bb | ||
|
|
87e3a275bb | ||
|
|
037b5c36a4 | ||
|
|
7d8b60fb14 | ||
|
|
0ad16fee53 | ||
|
|
249243aeb4 | ||
|
|
c208dc3579 | ||
|
|
ea93f2ba23 | ||
|
|
d910a0bb6a | ||
|
|
550fcfeddc | ||
|
|
c6910e5a1c | ||
|
|
8555edb521 | ||
|
|
139193ce29 | ||
|
|
1a87375ccd | ||
|
|
83cbef40f6 | ||
|
|
85b4fc75a1 | ||
|
|
f2e2da378f | ||
|
|
7c34bc9120 | ||
|
|
6f153f2acb | ||
|
|
8171083978 | ||
|
|
db5b9a59b4 | ||
|
|
6fa656ba11 | ||
|
|
de0682c1bb | ||
|
|
a6a32d8de4 | ||
|
|
bb14b269de | ||
|
|
14331d8bc2 | ||
|
|
1729464844 | ||
|
|
5fb9747285 | ||
|
|
394228d391 | ||
|
|
5d3c0cc6ec |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: benexl # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: benexl # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
30
.github/chatmodes/new-command.chatmode.md
vendored
Normal file
30
.github/chatmodes/new-command.chatmode.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: "Generate a new 'click' command following the project's lazy-loading pattern and service architecture."
|
||||
tools: ['codebase']
|
||||
---
|
||||
# viu: CLI Command Generation Mode
|
||||
|
||||
You are an expert on the `viu` CLI structure, which uses `click` and a custom `LazyGroup` for performance. Your task is to generate the boilerplate for a new command.
|
||||
|
||||
**First, ask the user if this is a top-level command (like `viu new-cmd`) or a subcommand (like `viu anilist new-sub-cmd`).**
|
||||
|
||||
---
|
||||
|
||||
### If Top-Level Command:
|
||||
|
||||
1. **File Location:** State that the new command file should be created at: `viu/cli/commands/{command_name}.py`.
|
||||
2. **Boilerplate:** Generate the `click.command()` function.
|
||||
* It **must** accept `config: AppConfig` as the first argument using `@click.pass_obj`.
|
||||
* It **must not** contain business logic. Instead, show how to instantiate a service from `viu.cli.service` and call its methods.
|
||||
3. **Registration:** Instruct the user to register the command by adding it to the `commands` dictionary in `viu/cli/cli.py`. Provide the exact line to add, like: `"new-cmd": "new_cmd.new_cmd_function"`.
|
||||
|
||||
---
|
||||
|
||||
### If Subcommand:
|
||||
|
||||
1. **Ask for Parent:** Ask for the parent command group (e.g., `anilist`, `registry`).
|
||||
2. **File Location:** State that the new command file should be created at: `viu/cli/commands/{parent_name}/commands/{command_name}.py`.
|
||||
3. **Boilerplate:** Generate the `click.command()` function, similar to the top-level command.
|
||||
4. **Registration:** Instruct the user to register the subcommand in the parent's `cmd.py` file (e.g., `viu/cli/commands/anilist/cmd.py`) by adding it to the `lazy_subcommands` dictionary within the `@click.group` decorator.
|
||||
|
||||
**Final Instruction:** Remind the user that if the command introduces new logic, it should be encapsulated in a new or existing **Service** class in the `viu/cli/service/` directory. The CLI command function should only handle argument parsing and calling the service.
|
||||
34
.github/chatmodes/new-component.chatmode.md
vendored
Normal file
34
.github/chatmodes/new-component.chatmode.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: "Scaffold the necessary files and code for a new Player or Selector component, including configuration."
|
||||
tools: ['codebase', 'search']
|
||||
---
|
||||
# viu: New Component Generation Mode
|
||||
|
||||
You are an expert on `viu`'s modular architecture. Your task is to help the developer add a new **Player** or **Selector** component.
|
||||
|
||||
**First, ask the user whether they want to create a 'Player' or a 'Selector'.** Then, follow the appropriate path below.
|
||||
|
||||
---
|
||||
|
||||
### If the user chooses 'Player':
|
||||
|
||||
1. **Scaffold Directory:** Create a directory at `viu/libs/player/{player_name}/`.
|
||||
2. **Implement `BasePlayer`:** Create a `player.py` file with a class `NewPlayer` that inherits from `viu.libs.player.base.BasePlayer`. Implement the `play` and `play_with_ipc` methods. The `play` method should use `subprocess` to call the player's executable.
|
||||
3. **Add Configuration:**
|
||||
* Instruct to create a new Pydantic model `NewPlayerConfig(OtherConfig)` in `viu/core/config/model.py`.
|
||||
* Add the new config model to the main `AppConfig`.
|
||||
* Add defaults in `viu/core/config/defaults.py` and descriptions in `viu/core/config/descriptions.py`.
|
||||
4. **Register Player:** Instruct to modify `viu/libs/player/player.py` by:
|
||||
* Adding the player name to the `PLAYERS` list.
|
||||
* Adding the instantiation logic to the `PlayerFactory.create` method.
|
||||
|
||||
---
|
||||
|
||||
### If the user chooses 'Selector':
|
||||
|
||||
1. **Scaffold Directory:** Create a directory at `viu/libs/selectors/{selector_name}/`.
|
||||
2. **Implement `BaseSelector`:** Create a `selector.py` file with a class `NewSelector` that inherits from `viu.libs.selectors.base.BaseSelector`. Implement the `choose`, `confirm`, and `ask` methods.
|
||||
3. **Add Configuration:** (Follow the same steps as for a Player).
|
||||
4. **Register Selector:**
|
||||
* Instruct to modify `viu/libs/selectors/selector.py` by adding the selector name to the `SELECTORS` list and the factory logic to `SelectorFactory.create`.
|
||||
* Instruct to update the `Literal` type hint for the `selector` field in `GeneralConfig` (`viu/core/config/model.py`).
|
||||
27
.github/chatmodes/new-provider.chatmode.md
vendored
Normal file
27
.github/chatmodes/new-provider.chatmode.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: "Scaffold and implement a new anime provider, following all architectural patterns of the viu project."
|
||||
tools: ['codebase', 'search', 'fetch']
|
||||
---
|
||||
# viu: New Provider Generation Mode
|
||||
|
||||
You are an expert on the `viu` codebase, specializing in its provider architecture. Your task is to guide the developer in creating a new anime provider. You must strictly adhere to the project's structure and coding conventions.
|
||||
|
||||
**Your process is as follows:**
|
||||
|
||||
1. **Ask for the Provider's Name:** First, ask the user for the name of the new provider (e.g., `gogoanime`, `crunchyroll`). Use this name (in lowercase) for all subsequent file and directory naming.
|
||||
|
||||
2. **Scaffold the Directory Structure:** Based on the name, state the required directory structure that needs to be created:
|
||||
`viu/libs/provider/anime/{provider_name}/`
|
||||
|
||||
3. **Scaffold the Core Files:** Generate the initial code for the following files inside the new directory. Ensure all code is fully type-hinted.
|
||||
|
||||
* **`__init__.py`**: Can be an empty file.
|
||||
* **`types.py`**: Create placeholder `TypedDict` models for the provider's specific API responses (e.g., `GogoAnimeSearchResult`, `GogoAnimeEpisode`).
|
||||
* **`mappers.py`**: Create empty mapping functions that will convert the provider-specific types into the generic types from `viu.libs.provider.anime.types`. For example: `map_to_search_results(data: GogoAnimeSearchPage) -> SearchResults:`.
|
||||
* **`provider.py`**: Generate the main provider class. It **MUST** inherit from `viu.libs.provider.anime.base.BaseAnimeProvider`. Include stubs for the required abstract methods: `search`, `get`, and `episode_streams`. Remind the user to use `httpx.Client` for requests and to call the mapper functions.
|
||||
|
||||
4. **Instruct on Registration:** Clearly state the two files that **must** be modified to register the new provider:
|
||||
* **`viu/libs/provider/anime/types.py`**: Add the new provider's name to the `ProviderName` enum.
|
||||
* **`viu/libs/provider/anime/provider.py`**: Add an entry to the `PROVIDERS_AVAILABLE` dictionary.
|
||||
|
||||
5. **Final Guidance:** Remind the developer to add any title normalization rules to `viu/assets/normalizer.json` if the provider uses different anime titles than AniList.
|
||||
73
.github/chatmodes/plan.chatmode.md
vendored
Normal file
73
.github/chatmodes/plan.chatmode.md
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
description: "Plan new features or bug fixes with architectural guidance for the viu project. Does not write implementation code."
|
||||
tools: ['codebase', 'search', 'githubRepo', 'fetch']
|
||||
model: "gpt-4o"
|
||||
---
|
||||
# viu: Feature & Fix Planner Mode
|
||||
|
||||
You are a senior software architect and project planner for the `viu` project. You are an expert in its layered architecture (`Core`, `Libs`, `Service`, `CLI`) and its commitment to modular, testable code.
|
||||
|
||||
Your primary goal is to help the user break down a feature request or bug report into a clear, actionable implementation plan.
|
||||
|
||||
**Crucially, you MUST NOT write the full implementation code.** Your output is the plan itself, which will then guide the developer (or another AI agent in "Edit" mode) to write the code.
|
||||
|
||||
### Your Process:
|
||||
|
||||
1. **Understand the Goal:** Start by asking the user to describe the feature they want to build or the bug they want to fix. If they reference a GitHub issue, use the `githubRepo` tool to get the context.
|
||||
|
||||
2. **Analyze the Codebase:** Use the `codebase` and `search` tools to understand how the request fits into the existing architecture. Identify all potentially affected modules, classes, and layers.
|
||||
|
||||
3. **Ask Clarifying Questions:** Ask questions to refine the requirements. For example:
|
||||
* "Will this feature need a new configuration option? If so, what should the default be?"
|
||||
* "How should this behave in the interactive TUI versus the direct CLI command?"
|
||||
* "Which architectural layer does the core logic for this fix belong in?"
|
||||
|
||||
4. **Generate the Implementation Plan:** Once you have enough information, produce a comprehensive plan in the following Markdown format:
|
||||
|
||||
---
|
||||
|
||||
### Implementation Plan: [Feature/Fix Name]
|
||||
|
||||
**1. Overview**
|
||||
> A brief, one-sentence summary of the goal.
|
||||
|
||||
**2. Architectural Impact Analysis**
|
||||
> This is the most important section. Detail which parts of the codebase will be touched and why.
|
||||
> - **Core Layer (`viu/core`):**
|
||||
> - *Config (`config/model.py`):* Will a new Pydantic model or field be needed?
|
||||
> - *Utils (`utils/`):* Are any new low-level, reusable functions required?
|
||||
> - *Exceptions (`exceptions.py`):* Does this introduce a new failure case that needs a custom exception?
|
||||
> - **Libs Layer (`viu/libs`):**
|
||||
> - *Media API (`media_api/`):* Does this involve a new call to the AniList API?
|
||||
> - *Provider (`provider/`):* Does this affect how data is scraped?
|
||||
> - *Player/Selector (`player/`, `selectors/`):* Does this change how we interact with external tools?
|
||||
> - **Service Layer (`viu/cli/service`):**
|
||||
> - Which service will orchestrate this logic? (e.g., `DownloadService`, `PlayerService`). Will a new service be needed?
|
||||
> - **CLI Layer (`viu/cli`):**
|
||||
> - *Commands (`commands/`):* Which `click` command(s) will expose this feature?
|
||||
> - *Interactive UI (`interactive/`):* Which TUI menu(s) need to be added or modified?
|
||||
|
||||
**3. Implementation Steps**
|
||||
> A step-by-step checklist for the developer.
|
||||
> 1. [ ] **Config:** Add `new_setting` to `GeneralConfig` in `core/config/model.py`.
|
||||
> 2. [ ] **Core:** Implement `new_util()` in `core/utils/helpers.py`.
|
||||
> 3. [ ] **Service:** Add method `handle_new_feature()` to `MyService`.
|
||||
> 4. [ ] **CLI:** Add `--new-feature` option to the `viu anilist search` command.
|
||||
> 5. [ ] **Tests:** Write a unit test for `new_util()` and an integration test for the service method.
|
||||
|
||||
**4. Configuration Changes**
|
||||
> If new settings are needed, list them here and specify which files to update.
|
||||
> - **`core/config/model.py`:** Add field `new_setting: bool`.
|
||||
> - **`core/config/defaults.py`:** Add `GENERAL_NEW_SETTING = False`.
|
||||
> - **`core/config/descriptions.py`:** Add `GENERAL_NEW_SETTING = "Description of the new setting."`
|
||||
|
||||
**5. Testing Strategy**
|
||||
> Briefly describe how to test this change.
|
||||
> - A unit test for the pure logic in the `Core` or `Libs` layer.
|
||||
> - An integration test for the `Service` layer.
|
||||
> - Manual verification steps for the CLI and interactive UI.
|
||||
|
||||
**6. Potential Risks & Open Questions**
|
||||
> - Will this change impact the performance of the provider scraping?
|
||||
> - Do we need to handle a case where the external API does not support this feature?
|
||||
---
|
||||
101
.github/copilot-instructions.md
vendored
Normal file
101
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# GitHub Copilot Instructions for the viu Repository
|
||||
|
||||
Hello, Copilot! This document provides instructions and context to help you understand the `viu` codebase. Following these guidelines will help you generate code that is consistent, maintainable, and aligned with the project's architecture.
|
||||
|
||||
## 1. High-Level Project Goal
|
||||
|
||||
`viu` is a command-line tool that brings the anime browsing, streaming, and management experience to the terminal. It integrates with metadata providers like AniList and scrapes streaming links from various anime provider websites. The core goals are efficiency, extensibility, and providing a powerful, scriptable user experience.
|
||||
|
||||
## 2. Core Architectural Concepts
|
||||
|
||||
The project follows a clean, layered architecture. When generating code, please adhere to this structure.
|
||||
|
||||
#### Layer 1: CLI (`viu/cli`)
|
||||
* **Purpose:** Handles user interaction, command parsing, and displaying output.
|
||||
* **Key Libraries:** `click` for command structure, `rich` for styled output.
|
||||
* **Interactive Mode:** The interactive TUI is managed by the `Session` object in `viu/cli/interactive/session.py`. It's a state machine where each menu is a function that returns the next `State` or an `InternalDirective` (like `BACK` or `EXIT`).
|
||||
* **Guideline:** **CLI files should not contain complex business logic.** They should parse arguments and delegate tasks to the Service Layer.
|
||||
|
||||
#### Layer 2: Service (`viu/cli/service`)
|
||||
* **Purpose:** Contains the core application logic. Services act as orchestrators, connecting the CLI layer with the various library components.
|
||||
* **Examples:** `DownloadService`, `PlayerService`, `MediaRegistryService`, `WatchHistoryService`.
|
||||
* **Guideline:** When adding new functionality (e.g., a new way to manage downloads), it should likely be implemented in a service or an existing service should be extended. Services are the "brains" of the application.
|
||||
|
||||
#### Layer 3: Libraries (`viu/libs`)
|
||||
* **Purpose:** A collection of independent, reusable modules with well-defined contracts (Abstract Base Classes).
|
||||
* **`media_api`:** Interfaces with metadata services like AniList. All new metadata clients **must** inherit from `BaseApiClient`.
|
||||
* **`provider`:** Interfaces with anime streaming websites. All new providers **must** inherit from `BaseAnimeProvider`.
|
||||
* **`player`:** Wrappers around external media players like MPV. All new players **must** inherit from `BasePlayer`.
|
||||
* **`selectors`:** Wrappers for interactive UI tools like FZF or Rofi. All new selectors **must** inherit from `BaseSelector`.
|
||||
* **Guideline:** Libraries should be self-contained and not depend on the CLI or Service layers. They receive configuration and perform their specific task.
|
||||
|
||||
#### Layer 4: Core (`viu/core`)
|
||||
* **Purpose:** Foundational code shared across the entire application.
|
||||
* **`config`:** Pydantic models defining the application's configuration structure. **This is the single source of truth for all settings.**
|
||||
* **`downloader`:** The underlying logic for downloading files (using `yt-dlp` or `httpx`).
|
||||
* **`exceptions`:** Custom exception classes used throughout the project.
|
||||
* **`utils`:** Common, low-level utility functions.
|
||||
* **Guideline:** Code in `core` should be generic and have no dependencies on other layers except for other `core` modules.
|
||||
|
||||
## 3. Key Technologies
|
||||
* **Dependency Management:** `uv` is used for all package management and task running. Refer to `pyproject.toml` for dependencies.
|
||||
* **Configuration:** **Pydantic** is used exclusively. The entire configuration is defined in `viu/core/config/model.py`.
|
||||
* **CLI Framework:** `click`. We use a custom `LazyGroup` to load commands on demand for faster startup.
|
||||
* **HTTP Client:** `httpx` is the standard for all network requests.
|
||||
|
||||
## 4. How to Add New Features
|
||||
|
||||
Follow these patterns to ensure your contributions fit the existing architecture.
|
||||
|
||||
### How to Add a New Provider
|
||||
1. **Create Directory:** Add a new folder in `viu/libs/provider/anime/newprovider/`.
|
||||
2. **Implement `BaseAnimeProvider`:** In `provider.py`, create a class `NewProvider` that inherits from `BaseAnimeProvider` and implement the `search`, `get`, and `episode_streams` methods.
|
||||
3. **Create Mappers:** In `mappers.py`, write functions to convert the provider's API/HTML data into the generic Pydantic models from `viu/libs/provider/anime/types.py` (e.g., `SearchResult`, `Anime`, `Server`).
|
||||
4. **Register Provider:**
|
||||
* Add the provider's name to the `ProviderName` enum in `viu/libs/provider/anime/types.py`.
|
||||
* Add it to the `PROVIDERS_AVAILABLE` dictionary in `viu/libs/provider/anime/provider.py`.
|
||||
|
||||
### How to Add a New Player
|
||||
1. **Create Directory:** Add a new folder in `viu/libs/player/newplayer/`.
|
||||
2. **Implement `BasePlayer`:** In `player.py`, create a class `NewPlayer` that inherits from `BasePlayer` and implement the `play` method. It should call the player's executable via `subprocess`.
|
||||
3. **Add Configuration:** If the player has settings, add a `NewPlayerConfig` Pydantic model in `viu/core/config/model.py`, and add it to the main `AppConfig`. Also add defaults and descriptions.
|
||||
4. **Register Player:** Add the player's name to the `PLAYERS` list and the factory logic in `viu/libs/player/player.py`.
|
||||
|
||||
### How to Add a New Selector
|
||||
1. **Create Directory:** Add a new folder in `viu/libs/selectors/newselector/`.
|
||||
2. **Implement `BaseSelector`:** In `selector.py`, create a class `NewSelector` that inherits from `BaseSelector` and implement `choose`, `confirm`, and `ask`.
|
||||
3. **Add Configuration:** If needed, add a `NewSelectorConfig` to `viu/core/config/model.py`.
|
||||
4. **Register Selector:** Add the selector's name to the `SELECTORS` list and the factory logic in `viu/libs/selectors/selector.py`. Update the `Literal` type hint for `selector` in `GeneralConfig`.
|
||||
|
||||
### How to Add a New CLI Command
|
||||
* **Top-Level Command (`viu my-command`):**
|
||||
1. Create `viu/cli/commands/my_command.py` with your `click.command()`.
|
||||
2. Register it in the `commands` dictionary in `viu/cli/cli.py`.
|
||||
* **Subcommand (`viu anilist my-subcommand`):**
|
||||
1. Create `viu/cli/commands/anilist/commands/my_subcommand.py`.
|
||||
2. Register it in the `lazy_subcommands` dictionary of the parent `click.group()` (e.g., in `viu/cli/commands/anilist/cmd.py`).
|
||||
|
||||
### How to Add a New Configuration Option
|
||||
1. **Add to Model:** Add the field to the appropriate Pydantic model in `viu/core/config/model.py`.
|
||||
2. **Add Default:** Add a default value in `viu/core/config/defaults.py`.
|
||||
3. **Add Description:** Add a user-friendly description in `viu/core/config/descriptions.py`.
|
||||
4. The config loader and CLI option generation will handle the rest automatically.
|
||||
|
||||
## 5. Code Style and Conventions
|
||||
* **Style:** `ruff` for formatting, `ruff` for linting. The `pre-commit` hooks handle this.
|
||||
* **Types:** Full type hinting is mandatory. All code must pass `pyright`.
|
||||
* **Commits:** Adhere to the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard.
|
||||
* **Logging:** Use Python's `logging` module. Do not use `print()` for debugging or informational messages in library or service code.
|
||||
|
||||
## 6. Do's and Don'ts
|
||||
|
||||
* ✅ **DO** use the abstract base classes (`BaseProvider`, `BasePlayer`, etc.) as contracts.
|
||||
* ✅ **DO** place business logic in the `service` layer.
|
||||
* ✅ **DO** use the Pydantic models in `viu/core/config/model.py` as the single source of truth for configuration.
|
||||
* ✅ **DO** use the `Context` object in interactive menus to access services and configuration.
|
||||
|
||||
* ❌ **DON'T** hardcode configuration values. Access them via the `config` object.
|
||||
* ❌ **DON'T** put complex logic directly into `click` command functions. Delegate to a service.
|
||||
* ❌ **DON'T** make direct `httpx` calls outside of a `provider` or `media_api` library.
|
||||
* ❌ **DON'T** introduce new dependencies without updating `pyproject.toml` and discussing it first.
|
||||
|
||||
47
.github/workflows/build.yml
vendored
47
.github/workflows/build.yml
vendored
@@ -1,38 +1,43 @@
|
||||
name: debug_build
|
||||
name: build
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Test Workflow"]
|
||||
types:
|
||||
- completed
|
||||
jobs:
|
||||
debug_build:
|
||||
build:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
- name: Install poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
- name: Install the project dependencies
|
||||
run: poetry install
|
||||
- name: build app
|
||||
run: poetry build
|
||||
enable-cache: true
|
||||
|
||||
- name: Build viu
|
||||
run: uv build
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fastanime_debug_build
|
||||
name: viu_debug_build
|
||||
path: |
|
||||
dist
|
||||
!dist/*.whl
|
||||
# - name: Run the automated tests (for example)
|
||||
# run: poetry run pytest -v
|
||||
|
||||
- name: Install nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Use GitHub Action built-in cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Nix Flake check (evaluation + tests)
|
||||
run: nix flake check
|
||||
|
||||
- name: Build the nix derivation
|
||||
run: nix build
|
||||
|
||||
29
.github/workflows/publish.yml
vendored
29
.github/workflows/publish.yml
vendored
@@ -1,17 +1,10 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# GitHub recommends pinning actions to a commit SHA.
|
||||
# To get a newer version, you will need to update the SHA.
|
||||
# You can also reference a tag or branch, but the action may change without warning.
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,11 +20,13 @@ jobs:
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Build release distributions
|
||||
run: |
|
||||
# NOTE: put your own distribution build steps here.
|
||||
python -m pip install build
|
||||
python -m build
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Build viu
|
||||
run: uv build
|
||||
|
||||
- name: Upload distributions
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -49,12 +44,6 @@ jobs:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
id-token: write
|
||||
|
||||
# Dedicated environments with protections for publishing are strongly recommended.
|
||||
environment:
|
||||
name: pypi
|
||||
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
|
||||
# url: https://pypi.org/p/YOURPROJECT
|
||||
|
||||
steps:
|
||||
- name: Retrieve release distributions
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
40
.github/workflows/test.yml
vendored
40
.github/workflows/test.yml
vendored
@@ -6,37 +6,35 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"] # List the Python versions you want to test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
- name: Install the project dependencies
|
||||
run: poetry install
|
||||
- name: run linter, formatters and sort imports
|
||||
run: |
|
||||
poetry run black .
|
||||
poetry run ruff check --output-format=github . --fix
|
||||
poetry run isort . --profile black
|
||||
- name: run type checking
|
||||
run: poetry run pyright
|
||||
- name: run tests
|
||||
run: poetry run pytest
|
||||
enable-cache: true
|
||||
|
||||
- name: Install the project
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Run linter and formater
|
||||
run: uv run ruff check --output-format=github
|
||||
|
||||
- name: Run type checking
|
||||
run: uv run pyright
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest tests
|
||||
|
||||
86
.gitignore
vendored
86
.gitignore
vendored
@@ -1,25 +1,16 @@
|
||||
# mine
|
||||
*.mp4
|
||||
*.mp3
|
||||
*.ass
|
||||
vids
|
||||
data/
|
||||
.project/
|
||||
fastanime.ini
|
||||
crashdump.txt
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
anixstream.ini
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
bin/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -39,7 +30,7 @@ MANIFEST
|
||||
# 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.
|
||||
*.manifest
|
||||
# *.spec
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
@@ -55,7 +46,7 @@ htmlcov/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
@@ -103,23 +94,36 @@ ipython_config.py
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
@@ -168,11 +172,41 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# 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/
|
||||
app/anixstream.ini
|
||||
app/settings.json
|
||||
app/user_data.json
|
||||
app/View/SearchScreen/.search_screen.py.un~
|
||||
app/View/SearchScreen/search_screen.py~
|
||||
app/user_data.json
|
||||
.buildozer
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# custom
|
||||
repomix-output.xml
|
||||
.project/
|
||||
result
|
||||
.direnv
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
python: python3.12
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pycqa/isort
|
||||
@@ -7,7 +7,7 @@ repos:
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black"] # Ensure compatibility with Black
|
||||
args: ["--profile", "black"]
|
||||
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v2.2.1
|
||||
@@ -19,17 +19,15 @@ repos:
|
||||
"--remove-unused-variables",
|
||||
"--remove-all-unused-imports",
|
||||
]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.4.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# - repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# rev: v0.4.10
|
||||
# hooks:
|
||||
# - id: ruff
|
||||
# args: [--fix]
|
||||
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
name: black
|
||||
language_version: python3.10 # to ensure compatibilty
|
||||
#language_version: python3.10
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.analysis.autoImportCompletions": true
|
||||
}
|
||||
208
CONTRIBUTIONS.md
Normal file
208
CONTRIBUTIONS.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Contributing to Viu
|
||||
|
||||
First off, thank you for considering contributing to Viu! We welcome any help, whether it's reporting a bug, proposing a feature, or writing code. This document will guide you through the process.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
There are many ways to contribute to the Viu project:
|
||||
|
||||
* **Reporting Bugs:** If you find a bug, please create an issue in our [issue tracker](https://github.com/Benexl/Viu/issues).
|
||||
* **Suggesting Enhancements:** Have an idea for a new feature or an improvement to an existing one? We'd love to hear it.
|
||||
* **Writing Code:** Help us fix bugs or implement new features.
|
||||
* **Improving Documentation:** Enhance our README, add examples, or clarify our contribution guidelines.
|
||||
* **Adding a Provider, Player, or Selector:** Extend Viu's capabilities by integrating new tools and services.
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
We follow the standard GitHub Fork & Pull Request workflow.
|
||||
|
||||
1. **Create an Issue:** Before starting work on a new feature or a significant bug fix, please [create an issue](https://github.com/Benexl/Viu/issues/new/choose) to discuss your idea. This allows us to give feedback and prevent duplicate work. For small bugs or documentation typos, you can skip this step.
|
||||
|
||||
2. **Fork the Repository:** Create your own fork of the Viu repository.
|
||||
|
||||
3. **Clone Your Fork:**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Viu.git
|
||||
cd Viu
|
||||
```
|
||||
|
||||
4. **Create a Branch:** Create a new branch for your changes. Use a descriptive name.
|
||||
```bash
|
||||
# For a new feature
|
||||
git checkout -b feat/my-new-feature
|
||||
|
||||
# For a bug fix
|
||||
git checkout -b fix/bug-description
|
||||
```
|
||||
|
||||
5. **Make Your Changes:** Write your code, following the guidelines below.
|
||||
|
||||
6. **Run Quality Checks:** Before committing, ensure your code passes all quality checks.
|
||||
```bash
|
||||
# Format, lint, and sort imports
|
||||
uv run ruff check --fix .
|
||||
uv run ruff format .
|
||||
|
||||
# Run type checking
|
||||
uv run pyright
|
||||
|
||||
# Run tests
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
7. **Commit Your Changes:** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. This helps us automate releases and makes the commit history more readable.
|
||||
```bash
|
||||
# Example commit messages
|
||||
git commit -m "feat: add support for XYZ provider"
|
||||
git commit -m "fix(anilist): correctly parse episode numbers with decimals"
|
||||
git commit -m "docs: update installation instructions in README"
|
||||
git commit -m "chore: upgrade httpx to version 0.28.1"
|
||||
```
|
||||
|
||||
8. **Push to Your Fork:**
|
||||
```bash
|
||||
git push origin feat/my-new-feature
|
||||
```
|
||||
|
||||
9. **Submit a Pull Request:** Open a pull request from your branch to the `master` branch of the main Viu repository. Provide a clear title and description of your changes.
|
||||
|
||||
## Setting Up Your Development Environment
|
||||
|
||||
### Prerequisites
|
||||
* Git
|
||||
* Python 3.10+
|
||||
* [uv](https://github.com/astral-sh/uv) (recommended)
|
||||
* **External Tools (for full functionality):** `mpv`, `fzf`, `rofi`, `webtorrent-cli`, `ffmpeg`.
|
||||
|
||||
### Nix / NixOS Users
|
||||
The easiest way to get a development environment with all dependencies is to use our Nix flake.
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
This command will drop you into a shell with all the necessary tools and a Python environment ready to go.
|
||||
|
||||
### Standard Setup (uv + venv)
|
||||
|
||||
1. **Clone your fork** (as described above).
|
||||
|
||||
2. **Create and activate a virtual environment:**
|
||||
```bash
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
3. **Install all dependencies:** This command installs both runtime and development dependencies, including all optional extras.
|
||||
```bash
|
||||
uv sync --all-extras --dev
|
||||
```
|
||||
|
||||
4. **Set up pre-commit hooks:** This will automatically run linters and formatters before each commit, ensuring your code meets our quality standards.
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
To maintain code quality and consistency, please adhere to the following guidelines.
|
||||
|
||||
* **Formatting:** We use **Black** for code formatting and **isort** (via Ruff) for import sorting. The pre-commit hooks will handle this for you.
|
||||
* **Linting:** We use **Ruff** for linting. Please ensure your code has no linting errors before submitting a PR.
|
||||
* **Type Hinting:** All new code should be fully type-hinted and pass `pyright` checks. We rely on Pydantic for data validation and configuration, so leverage it where possible.
|
||||
* **Modularity and Architecture:**
|
||||
* **Services:** Business logic is organized into services (e.g., `PlayerService`, `DownloadService`).
|
||||
* **Factories:** Use factory patterns (`create_provider`, `create_selector`) for creating instances of different implementations.
|
||||
* **Configuration:** All configuration is managed through Pydantic models in `viu/core/config/model.py`. When adding new config options, update the model, defaults, and descriptions.
|
||||
* **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard.
|
||||
* **Testing:** New features should be accompanied by tests. Bug fixes should ideally include a regression test.
|
||||
|
||||
## How to Add a New Provider
|
||||
|
||||
Adding a new anime provider is a great way to contribute. Here are the steps:
|
||||
|
||||
1. **Create a New Provider Directory:** Inside `viu/libs/provider/anime/`, create a new directory with the provider's name (e.g., `viu/libs/provider/anime/newprovider/`).
|
||||
|
||||
2. **Implement the Provider:**
|
||||
* Create a `provider.py` file.
|
||||
* Define a class (e.g., `NewProviderApi`) that inherits from `BaseAnimeProvider`.
|
||||
* Implement the abstract methods: `search`, `get`, and `episode_streams`.
|
||||
* Create `mappers.py` to convert the provider's data structures into the generic types defined in `viu/libs/provider/anime/types.py`.
|
||||
* Create `types.py` for any provider-specific data structures you need.
|
||||
* If the provider requires complex scraping, place extractor logic in an `extractors/` subdirectory.
|
||||
|
||||
3. **Register the Provider:**
|
||||
* Add your new provider to the `ProviderName` enum in `viu/libs/provider/anime/types.py`.
|
||||
* Register it in the `PROVIDERS_AVAILABLE` dictionary in `viu/libs/provider/anime/provider.py`.
|
||||
|
||||
4. **Add Normalization Rules (Optional):** If the provider uses different anime titles than AniList, add mappings to `viu/assets/normalizer.json`.
|
||||
|
||||
## How to Add a New Player
|
||||
|
||||
1. **Create a New Player Directory:** Inside `viu/libs/player/`, create a directory for your player (e.g., `viu/libs/player/myplayer/`).
|
||||
|
||||
2. **Implement the Player Class:**
|
||||
* In `myplayer/player.py`, create a class (e.g., `MyPlayer`) that inherits from `BasePlayer`.
|
||||
* Implement the required abstract methods: `play(self, params: PlayerParams)` and `play_with_ipc(self, params: PlayerParams, socket_path: str)`. The IPC method is optional but recommended for advanced features.
|
||||
* The `play` method should handle launching the player as a subprocess and return a `PlayerResult`.
|
||||
|
||||
3. **Add Configuration (if needed):**
|
||||
* If your player has configurable options, add a new Pydantic model (e.g., `MyPlayerConfig`) in `viu/core/config/model.py`. It should inherit from `OtherConfig`.
|
||||
* Add this new config model as a field in the main `AppConfig` model.
|
||||
* Add default values in `defaults.py` and descriptions in `descriptions.py`.
|
||||
|
||||
4. **Register the Player:**
|
||||
* Add your player's name to the `PLAYERS` list in `viu/libs/player/player.py`.
|
||||
* Add the logic to instantiate your player class within the `PlayerFactory.create` method.
|
||||
|
||||
## How to Add a New Selector
|
||||
|
||||
1. **Create a New Selector Directory:** Inside `viu/libs/selectors/`, create a new directory (e.g., `viu/libs/selectors/myselector/`).
|
||||
|
||||
2. **Implement the Selector Class:**
|
||||
* In `myselector/selector.py`, create a class (e.g., `MySelector`) that inherits from `BaseSelector`.
|
||||
* Implement the abstract methods: `choose`, `confirm`, and `ask`.
|
||||
* Optionally, you can override `choose_multiple` and `search` for more advanced functionality.
|
||||
|
||||
3. **Add Configuration (if needed):** Follow the same configuration steps as for adding a new player.
|
||||
|
||||
4. **Register the Selector:**
|
||||
* Add your selector's name to the `SELECTORS` list in `viu/libs/selectors/selector.py`.
|
||||
* Add the instantiation logic to the `SelectorFactory.create` method.
|
||||
* Update the `Literal` type hint for the `selector` field in `GeneralConfig` (`viu/core/config/model.py`).
|
||||
|
||||
## How to Add a New CLI Command or Service
|
||||
|
||||
Our CLI uses `click` and a `LazyGroup` class to load commands on demand.
|
||||
|
||||
### Adding a Top-Level Command (e.g., `viu my-command`)
|
||||
|
||||
1. **Create the Command File:** Create a new Python file in `viu/cli/commands/` (e.g., `my_command.py`). This file should contain your `click.command()` function.
|
||||
|
||||
2. **Register the Command:** In `viu/cli/cli.py`, add your command to the `commands` dictionary.
|
||||
```python
|
||||
commands = {
|
||||
# ... existing commands
|
||||
"my-command": "my_command.my_command_function",
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a Subcommand (e.g., `viu anilist my-subcommand`)
|
||||
|
||||
1. **Create the Command File:** Place your new command file inside the appropriate subdirectory, for example, `viu/cli/commands/anilist/commands/my_subcommand.py`.
|
||||
|
||||
2. **Register the Subcommand:** In the parent command's entry point file (e.g., `viu/cli/commands/anilist/cmd.py`), add your subcommand to the `commands` dictionary within the `LazyGroup`.
|
||||
```python
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
# ... other options
|
||||
lazy_subcommands={
|
||||
# ... existing subcommands
|
||||
"my-subcommand": "my_subcommand.my_subcommand_function",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Creating a Service
|
||||
If your command involves complex logic, consider creating a service in `viu/cli/service/` to keep the business logic separate from the command-line interface. This service can then be instantiated and used within your `click` command function. This follows the existing pattern for services like `DownloadService` and `PlayerService`.
|
||||
|
||||
---
|
||||
Thank you for contributing to Viu
|
||||
38
DISCLAIMER.md
Normal file
38
DISCLAIMER.md
Normal file
@@ -0,0 +1,38 @@
|
||||
<h1 align="center">Disclaimer</h1>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<h2>This project: viu</h2>
|
||||
|
||||
<br>
|
||||
|
||||
The core aim of this project is to co-relate automation and efficiency to extract what is provided to a user on the internet. All content available through the project is hosted by external non-affiliated sources.
|
||||
|
||||
<br>
|
||||
|
||||
<b>All content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site.</b>
|
||||
|
||||
Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to only make requests associated with getting the content served by the sites.
|
||||
|
||||
<b>
|
||||
|
||||
This project is to be used at the user's own risk, based on their government and laws.
|
||||
|
||||
This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk.
|
||||
|
||||
</b>
|
||||
|
||||
<br>
|
||||
|
||||
<h2>DMCA and Copyright Infrigements</h3>
|
||||
|
||||
<br>
|
||||
|
||||
<b>
|
||||
|
||||
A browser is a tool, and the maliciousness of the tool is directly based on the user.
|
||||
</b>
|
||||
|
||||
This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. This is one of the main reasons the sites are listed in this project.
|
||||
|
||||
<b>Do not harass the developer. Any personal information about the developer is intentionally not made public. Exploiting such information without consent in regards to this topic will lead to legal actions by the developer themselves.</b>
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,10 +0,0 @@
|
||||
FROM ubuntu
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install python3
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install pipx
|
||||
RUN pipx ensurepath
|
||||
COPY . /fastanime
|
||||
WORKDIR /fastanime
|
||||
RUN pipx install .
|
||||
CMD ["bash"]
|
||||
7
bundle/Dockerfile
Normal file
7
bundle/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
COPY . /viu
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
WORKDIR /viu
|
||||
RUN uv tool install .
|
||||
CMD ["bash"]
|
||||
65
bundle/pyinstaller.spec
Normal file
65
bundle/pyinstaller.spec
Normal file
@@ -0,0 +1,65 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Collect all required data files
|
||||
datas = [
|
||||
('viu/assets/*', 'viu/assets'),
|
||||
]
|
||||
|
||||
# Collect all required hidden imports
|
||||
hiddenimports = [
|
||||
'click',
|
||||
'rich',
|
||||
'requests',
|
||||
'yt_dlp',
|
||||
'python_mpv',
|
||||
'fuzzywuzzy',
|
||||
'viu',
|
||||
] + collect_submodules('viu')
|
||||
|
||||
a = Analysis(
|
||||
['./viu/viu.py'], # Changed entry point
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
strip=True, # Strip debug information
|
||||
optimize=2 # Optimize bytecode noarchive=False
|
||||
)
|
||||
|
||||
pyz = PYZ(
|
||||
a.pure,
|
||||
a.zipped_data,
|
||||
optimize=2 # Optimize bytecode cipher=block_cipher
|
||||
)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='viu',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon='viu/assets/logo.ico'
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
_fastanime_completion() {
|
||||
_viu_completion() {
|
||||
local IFS=$'\n'
|
||||
local response
|
||||
|
||||
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _FASTANIME_COMPLETE=bash_complete $1)
|
||||
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _VIU_COMPLETE=bash_complete $1)
|
||||
|
||||
for completion in $response; do
|
||||
IFS=',' read type value <<< "$completion"
|
||||
@@ -21,9 +21,9 @@ _fastanime_completion() {
|
||||
return 0
|
||||
}
|
||||
|
||||
_fastanime_completion_setup() {
|
||||
complete -o nosort -F _fastanime_completion fastanime
|
||||
_viu_completion_setup() {
|
||||
complete -o nosort -F _viu_completion viu
|
||||
}
|
||||
|
||||
_fastanime_completion_setup;
|
||||
_viu_completion_setup;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function _fastanime_completion;
|
||||
set -l response (env _FASTANIME_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fastanime);
|
||||
function _viu_completion;
|
||||
set -l response (env _VIU_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) viu);
|
||||
|
||||
for completion in $response;
|
||||
set -l metadata (string split "," $completion);
|
||||
@@ -14,5 +14,5 @@ function _fastanime_completion;
|
||||
end;
|
||||
end;
|
||||
|
||||
complete --no-files --command fastanime --arguments "(_fastanime_completion)";
|
||||
complete --no-files --command viu --arguments "(_viu_completion)";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#compdef fastanime
|
||||
#compdef viu
|
||||
|
||||
_fastanime_completion() {
|
||||
_viu_completion() {
|
||||
local -a completions
|
||||
local -a completions_with_descriptions
|
||||
local -a response
|
||||
(( ! $+commands[fastanime] )) && return 1
|
||||
(( ! $+commands[viu] )) && return 1
|
||||
|
||||
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _FASTANIME_COMPLETE=zsh_complete fastanime)}")
|
||||
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _VIU_COMPLETE=zsh_complete viu)}")
|
||||
|
||||
for type key descr in ${response}; do
|
||||
if [[ "$type" == "plain" ]]; then
|
||||
@@ -33,9 +33,9 @@ _fastanime_completion() {
|
||||
|
||||
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
|
||||
# autoload from fpath, call function directly
|
||||
_fastanime_completion "$@"
|
||||
_viu_completion "$@"
|
||||
else
|
||||
# eval/source/. command, register function for later
|
||||
compdef _fastanime_completion fastanime
|
||||
compdef _viu_completion viu
|
||||
fi
|
||||
|
||||
14
dev/generate_completions.sh
Normal file
14
dev/generate_completions.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
APP_DIR="$(
|
||||
cd -- "$(dirname "$0")" >/dev/null 2>&1
|
||||
pwd -P
|
||||
)"
|
||||
|
||||
# fish shell completions
|
||||
_VIU_COMPLETE=fish_source viu >"$APP_DIR/completions/viu.fish"
|
||||
|
||||
# zsh completions
|
||||
_VIU_COMPLETE=zsh_source viu >"$APP_DIR/completions/viu.zsh"
|
||||
|
||||
# bash completions
|
||||
_VIU_COMPLETE=bash_source viu >"$APP_DIR/completions/viu.bash"
|
||||
2460
dev/generated/anilist/tags.json
Normal file
2460
dev/generated/anilist/tags.json
Normal file
File diff suppressed because it is too large
Load Diff
16
dev/make_release
Normal file
16
dev/make_release
Normal file
@@ -0,0 +1,16 @@
|
||||
#! /usr/bin/env sh
|
||||
CLI_DIR="$(dirname "$(realpath "$0")")"
|
||||
VERSION=$1
|
||||
[ -z "$VERSION" ] && echo no version provided && exit 1
|
||||
[ "$VERSION" = "current" ] && viu --version && exit 0
|
||||
sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" &&
|
||||
sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/viu/__init__.py" &&
|
||||
sed -i "s/version = .*/version = \"$VERSION\";/" "$CLI_DIR/flake.nix" &&
|
||||
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/viu/__init__.py" "$CLI_DIR/flake.nix" &&
|
||||
git commit -m "chore: bump version (v$VERSION)" &&
|
||||
# nix flake lock &&
|
||||
uv lock &&
|
||||
git stage "$CLI_DIR/flake.lock" "$CLI_DIR/uv.lock" &&
|
||||
git commit -m "chore: update lock files" &&
|
||||
git push &&
|
||||
gh release create "v$VERSION"
|
||||
8
fa
Executable file → Normal file
8
fa
Executable file → Normal file
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@"
|
||||
cd "$(dirname "$(realpath "$0")")" || exit 1
|
||||
exec python -m fastanime "$@"
|
||||
provider_type=$1
|
||||
provider_name=$2
|
||||
[ -z "$provider_type" ] && echo "Please specify provider type" && exit
|
||||
[ -z "$provider_name" ] && echo "Please specify provider type" && exit
|
||||
uv run python -m viu_cli.libs.provider.${provider_type}.${provider_name}.provider
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .libs.anime_provider import anime_sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Iterator
|
||||
|
||||
from .libs.anime_provider.types import Anime, SearchResults, Server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: improve performance of this class and add cool features like auto retry
|
||||
class AnimeProvider:
|
||||
"""Class that manages all anime sources adding some extra functionality to them.
|
||||
Attributes:
|
||||
PROVIDERS: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
dynamic: [TODO:attribute]
|
||||
retries: [TODO:attribute]
|
||||
anime_provider: [TODO:attribute]
|
||||
"""
|
||||
|
||||
PROVIDERS = list(anime_sources.keys())
|
||||
provider = PROVIDERS[0]
|
||||
|
||||
def __init__(self, provider, dynamic=False, retries=0) -> None:
|
||||
self.provider = provider
|
||||
self.dynamic = dynamic
|
||||
self.retries = retries
|
||||
self.lazyload_provider(self.provider)
|
||||
|
||||
def lazyload_provider(self, provider):
|
||||
"""updates the current provider being used"""
|
||||
_, anime_provider_cls_name = anime_sources[provider].split(".", 1)
|
||||
package = f"fastanime.libs.anime_provider.{provider}"
|
||||
provider_api = importlib.import_module(".api", package)
|
||||
anime_provider = getattr(provider_api, anime_provider_cls_name)
|
||||
self.anime_provider = anime_provider()
|
||||
|
||||
def search_for_anime(
|
||||
self,
|
||||
user_query,
|
||||
translation_type,
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
) -> "SearchResults | None":
|
||||
"""core abstraction over all providers search functionality
|
||||
|
||||
Args:
|
||||
user_query ([TODO:parameter]): [TODO:description]
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
nsfw ([TODO:parameter]): [TODO:description]
|
||||
unknown ([TODO:parameter]): [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.search_for_anime(
|
||||
user_query, translation_type, nsfw, unknown
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
|
||||
results = None
|
||||
|
||||
return results
|
||||
|
||||
def get_anime(
|
||||
self,
|
||||
anime_id: str,
|
||||
) -> "Anime | None":
|
||||
"""core abstraction over getting info of an anime from all providers
|
||||
|
||||
Args:
|
||||
anime_id: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.get_anime(anime_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
|
||||
|
||||
results = None
|
||||
return results
|
||||
|
||||
def get_episode_streams(
|
||||
self,
|
||||
anime,
|
||||
episode: str,
|
||||
translation_type: str,
|
||||
) -> "Iterator[Server] | None":
|
||||
"""core abstractions for getting juicy streams from all providers
|
||||
|
||||
Args:
|
||||
anime ([TODO:parameter]): [TODO:description]
|
||||
episode: [TODO:description]
|
||||
translation_type: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.get_episode_streams(
|
||||
anime, episode, translation_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
|
||||
|
||||
results = None
|
||||
return results
|
||||
@@ -1,4 +0,0 @@
|
||||
"""This package exist as away to expose functions and classes that my be useful to a developer using the fastanime library
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
@@ -1,34 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
|
||||
|
||||
|
||||
# TODO: Add formating options for the final date
|
||||
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
|
||||
if anilist_date_object:
|
||||
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def format_anilist_timestamp(anilist_timestamp: int | None):
|
||||
if anilist_timestamp:
|
||||
return datetime.fromtimestamp(anilist_timestamp).strftime("%d/%m/%Y %H:%M:%S")
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def format_list_data_with_comma(data: list | None):
|
||||
if data:
|
||||
return ", ".join(data)
|
||||
else:
|
||||
return "None"
|
||||
|
||||
|
||||
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
|
||||
if airing_episode:
|
||||
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
|
||||
else:
|
||||
return "Completed"
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
Just contains some useful data used across the codebase
|
||||
"""
|
||||
|
||||
# useful incases where the anilist title is too different from the provider title
|
||||
anime_normalizer_raw = {
|
||||
"allanime": {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
},
|
||||
"aniwatch": {"My Star": "Oshi no Ko"},
|
||||
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
|
||||
}
|
||||
|
||||
|
||||
def get_anime_normalizer():
|
||||
"""Used because there are different providers"""
|
||||
import os
|
||||
|
||||
current_provider = os.environ["CURRENT_FASTANIME_PROVIDER"]
|
||||
return anime_normalizer_raw[current_provider]
|
||||
|
||||
|
||||
anime_normalizer = get_anime_normalizer()
|
||||
@@ -1,163 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
import yt_dlp
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YtDLPDownloader:
|
||||
downloads_queue = Queue()
|
||||
|
||||
def _worker(self):
|
||||
while True:
|
||||
task, args = self.downloads_queue.get()
|
||||
try:
|
||||
task(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Something went wrong {e}")
|
||||
self.downloads_queue.task_done()
|
||||
|
||||
def __init__(self):
|
||||
self._thread = Thread(target=self._worker)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
def _download_file(
|
||||
self,
|
||||
url: str,
|
||||
anime_title: str,
|
||||
episode_title: str,
|
||||
download_dir: str,
|
||||
silent: bool,
|
||||
vid_format: str = "best",
|
||||
force_unknown_ext=False,
|
||||
verbose=False,
|
||||
headers={},
|
||||
sub="",
|
||||
merge=False,
|
||||
clean=False,
|
||||
prompt=True,
|
||||
):
|
||||
"""Helper function that downloads anime given url and path details
|
||||
|
||||
Args:
|
||||
url: [TODO:description]
|
||||
anime_title: [TODO:description]
|
||||
episode_title: [TODO:description]
|
||||
download_dir: [TODO:description]
|
||||
silent: [TODO:description]
|
||||
vid_format: [TODO:description]
|
||||
"""
|
||||
anime_title = sanitize_filename(anime_title)
|
||||
episode_title = sanitize_filename(episode_title)
|
||||
ydl_opts = {
|
||||
# Specify the output path and template
|
||||
"http_headers": headers,
|
||||
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
|
||||
"silent": silent,
|
||||
"verbose": verbose,
|
||||
"format": vid_format,
|
||||
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
|
||||
}
|
||||
urls = [url]
|
||||
if sub:
|
||||
urls.append(sub)
|
||||
vid_path = ""
|
||||
sub_path = ""
|
||||
for i, url in enumerate(urls):
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
if not info:
|
||||
continue
|
||||
if i == 0:
|
||||
vid_path = info["requested_downloads"][0]["filepath"]
|
||||
else:
|
||||
sub_path = info["requested_downloads"][0]["filepath"]
|
||||
if sub_path and vid_path and merge:
|
||||
self.merge_subtitles(vid_path, sub_path, clean, prompt)
|
||||
|
||||
def merge_subtitles(self, video_path, sub_path, clean, prompt):
|
||||
# Extract the directory and filename
|
||||
video_dir = os.path.dirname(video_path)
|
||||
video_name = os.path.basename(video_path)
|
||||
video_name, _ = os.path.splitext(video_name)
|
||||
video_name += ".mkv"
|
||||
|
||||
FFMPEG_EXECUTABLE = shutil.which("ffmpeg")
|
||||
if not FFMPEG_EXECUTABLE:
|
||||
print("[yellow bold]WARNING: [/]FFmpeg not found")
|
||||
return
|
||||
# Create a temporary directory
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Temporary output path in the temporary directory
|
||||
temp_output_path = os.path.join(temp_dir, video_name)
|
||||
# FFmpeg command to merge subtitles
|
||||
command = [
|
||||
FFMPEG_EXECUTABLE,
|
||||
"-hide_banner",
|
||||
"-i",
|
||||
video_path,
|
||||
"-i",
|
||||
sub_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-map",
|
||||
"0",
|
||||
"-map",
|
||||
"1",
|
||||
temp_output_path,
|
||||
]
|
||||
|
||||
# Run the command
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
# Move the file back to the original directory with the original name
|
||||
final_output_path = os.path.join(video_dir, video_name)
|
||||
|
||||
if os.path.exists(final_output_path):
|
||||
if not prompt or Confirm.ask(
|
||||
f"File exists({final_output_path}) would you like to overwrite it",
|
||||
default=True,
|
||||
):
|
||||
# move file to dest
|
||||
os.remove(final_output_path)
|
||||
shutil.move(temp_output_path, final_output_path)
|
||||
else:
|
||||
shutil.move(temp_output_path, final_output_path)
|
||||
# clean up
|
||||
if clean:
|
||||
print("[cyan]Cleaning original files...[/]")
|
||||
os.remove(video_path)
|
||||
os.remove(sub_path)
|
||||
|
||||
print(
|
||||
f"[green bold]Subtitles merged successfully.[/] Output file: {final_output_path}"
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[red bold]Error[/] during merging subtitles: {e}")
|
||||
except Exception as e:
|
||||
print(f"[red bold]An error[/] occurred: {e}")
|
||||
|
||||
# WARN: May remove this legacy functionality
|
||||
def download_file(self, url: str, title, silent=True):
|
||||
"""A helper that just does things in the background
|
||||
|
||||
Args:
|
||||
title ([TODO:parameter]): [TODO:description]
|
||||
silent ([TODO:parameter]): [TODO:description]
|
||||
url: [TODO:description]
|
||||
"""
|
||||
self.downloads_queue.put((self._download_file, (url, title, silent)))
|
||||
|
||||
|
||||
downloader = YtDLPDownloader()
|
||||
@@ -1,44 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
from .data import anime_normalizer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_by_episode_number(filename: str):
|
||||
import re
|
||||
|
||||
match = re.search(r"\d+", filename)
|
||||
return int(match.group()) if match else 0
|
||||
|
||||
|
||||
def anime_title_percentage_match(
|
||||
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
|
||||
) -> float:
|
||||
"""Returns the percentage match between the possible title and user title
|
||||
|
||||
Args:
|
||||
possible_user_requested_anime_title (str): an Animdl search result title
|
||||
title (str): the anime title the user wants
|
||||
|
||||
Returns:
|
||||
int: the percentage match
|
||||
"""
|
||||
possible_user_requested_anime_title = anime_normalizer.get(
|
||||
possible_user_requested_anime_title, possible_user_requested_anime_title
|
||||
)
|
||||
# compares both the romaji and english names and gets highest Score
|
||||
title_a = str(anime["title"]["romaji"])
|
||||
title_b = str(anime["title"]["english"])
|
||||
percentage_ratio = max(
|
||||
fuzz.ratio(title_a.lower(), possible_user_requested_anime_title.lower()),
|
||||
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
|
||||
)
|
||||
logger.info(f"{locals()}")
|
||||
return percentage_ratio
|
||||
@@ -1,20 +0,0 @@
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
raise ImportError(
|
||||
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp"
|
||||
) # noqa: F541
|
||||
|
||||
|
||||
__version__ = "v2.5.0"
|
||||
|
||||
APP_NAME = "FastAnime"
|
||||
AUTHOR = "Benex254"
|
||||
GIT_REPO = "github.com"
|
||||
REPO = f"{GIT_REPO}/{AUTHOR}/{APP_NAME}"
|
||||
|
||||
|
||||
def FastAnime():
|
||||
from .cli import run_cli
|
||||
|
||||
run_cli()
|
||||
@@ -1,3 +0,0 @@
|
||||
from .libs.anilist.api import AniListApi
|
||||
|
||||
AniList = AniListApi()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
@@ -1,299 +0,0 @@
|
||||
import signal
|
||||
|
||||
import click
|
||||
|
||||
from .. import __version__
|
||||
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
|
||||
from .commands import LazyGroup
|
||||
|
||||
commands = {
|
||||
"search": "search.search",
|
||||
"download": "download.download",
|
||||
"anilist": "anilist.anilist",
|
||||
"config": "config.config",
|
||||
"downloads": "downloads.downloads",
|
||||
"cache": "cache.cache",
|
||||
"completions": "completions.completions",
|
||||
"update": "update.update",
|
||||
"grab": "grab.grab",
|
||||
}
|
||||
|
||||
|
||||
# handle keyboard interupt
|
||||
def handle_exit(signum, frame):
|
||||
from click import clear
|
||||
|
||||
from .utils.tools import exit_app
|
||||
|
||||
clear()
|
||||
|
||||
exit_app()
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, handle_exit)
|
||||
|
||||
|
||||
@click.group(
|
||||
lazy_subcommands=commands,
|
||||
cls=LazyGroup,
|
||||
help="A command line application for streaming anime that provides a complete and featureful interface",
|
||||
short_help="Stream Anime",
|
||||
)
|
||||
@click.version_option(__version__, "--version")
|
||||
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
|
||||
@click.option("--log", help="Allow logging to stdout", is_flag=True)
|
||||
@click.option("--log-file", help="Allow logging to a file", is_flag=True)
|
||||
@click.option("--rich-traceback", help="Use rich to output tracebacks", is_flag=True)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--provider",
|
||||
type=click.Choice(list(anime_sources.keys()), case_sensitive=False),
|
||||
help="Provider of your choice",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--server",
|
||||
type=click.Choice([*SERVERS_AVAILABLE, "top"]),
|
||||
help="Server of choice",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--format",
|
||||
type=str,
|
||||
help="yt-dlp format to use",
|
||||
)
|
||||
@click.option(
|
||||
"-c/-no-c",
|
||||
"--continue/--no-continue",
|
||||
"continue_",
|
||||
type=bool,
|
||||
help="Continue from last episode?",
|
||||
)
|
||||
@click.option(
|
||||
"--local-history/--remote-history",
|
||||
type=bool,
|
||||
help="Whether to continue from local history or remote history",
|
||||
)
|
||||
@click.option(
|
||||
"--skip/--no-skip",
|
||||
type=bool,
|
||||
help="Skip opening and ending theme songs?",
|
||||
)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quality",
|
||||
type=click.Choice(
|
||||
[
|
||||
"360",
|
||||
"480",
|
||||
"720",
|
||||
"1080",
|
||||
]
|
||||
),
|
||||
help="set the quality of the stream",
|
||||
)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--translation-type",
|
||||
type=click.Choice(["dub", "sub"]),
|
||||
help="Anime language[dub/sub]",
|
||||
)
|
||||
@click.option(
|
||||
"-sl",
|
||||
"--sub-lang",
|
||||
help="Set the preferred language for subs",
|
||||
)
|
||||
@click.option(
|
||||
"-A/-no-A",
|
||||
"--auto-next/--no-auto-next",
|
||||
type=bool,
|
||||
help="Auto select next episode?",
|
||||
)
|
||||
@click.option(
|
||||
"-a/-no-a",
|
||||
"--auto-select/--no-auto-select",
|
||||
type=bool,
|
||||
help="Auto select anime title?",
|
||||
)
|
||||
@click.option(
|
||||
"--normalize-titles/--no-normalize-titles",
|
||||
type=bool,
|
||||
help="whether to normalize anime and episode titls given by providers",
|
||||
)
|
||||
@click.option("-d", "--downloads-dir", type=click.Path(), help="Downloads location")
|
||||
@click.option("--fzf", is_flag=True, help="Use fzf for the ui")
|
||||
@click.option("--default", is_flag=True, help="Use the default interface")
|
||||
@click.option("--preview", is_flag=True, help="Show preview when using fzf")
|
||||
@click.option("--no-preview", is_flag=True, help="Dont show preview when using fzf")
|
||||
@click.option(
|
||||
"--icons/--no-icons",
|
||||
type=bool,
|
||||
help="Use icons in the interfaces",
|
||||
)
|
||||
@click.option("--dub", help="Set the translation type to dub", is_flag=True)
|
||||
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
|
||||
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
|
||||
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
|
||||
@click.option(
|
||||
"--rofi-theme-confirm",
|
||||
help="Rofi theme to use for the confirm prompt",
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"--rofi-theme-input",
|
||||
help="Rofi theme to use for the user input prompt",
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"--use-python-mpv/--use-default-player", help="Whether to use python-mpv", type=bool
|
||||
)
|
||||
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
|
||||
@click.pass_context
|
||||
def run_cli(
|
||||
ctx: click.Context,
|
||||
manga,
|
||||
log,
|
||||
log_file,
|
||||
rich_traceback,
|
||||
provider,
|
||||
server,
|
||||
format,
|
||||
continue_,
|
||||
local_history,
|
||||
skip,
|
||||
translation_type,
|
||||
sub_lang,
|
||||
quality,
|
||||
auto_next,
|
||||
auto_select,
|
||||
normalize_titles,
|
||||
downloads_dir,
|
||||
fzf,
|
||||
default,
|
||||
preview,
|
||||
no_preview,
|
||||
icons,
|
||||
dub,
|
||||
sub,
|
||||
rofi,
|
||||
rofi_theme,
|
||||
rofi_theme_confirm,
|
||||
rofi_theme_input,
|
||||
use_python_mpv,
|
||||
sync_play,
|
||||
):
|
||||
from .config import Config
|
||||
|
||||
ctx.obj = Config()
|
||||
ctx.obj.manga = manga
|
||||
if log:
|
||||
import logging
|
||||
|
||||
from rich.logging import RichHandler
|
||||
|
||||
FORMAT = "%(message)s"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("logging has been initialized")
|
||||
elif log_file:
|
||||
import logging
|
||||
|
||||
from ..constants import LOG_FILE_PATH
|
||||
|
||||
format = "%(asctime)s%(levelname)s: %(message)s"
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=LOG_FILE_PATH,
|
||||
format=format,
|
||||
datefmt="[%d/%m/%Y@%H:%M:%S]",
|
||||
filemode="w",
|
||||
)
|
||||
else:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.CRITICAL)
|
||||
if rich_traceback:
|
||||
from rich.traceback import install
|
||||
|
||||
install()
|
||||
|
||||
if sync_play:
|
||||
ctx.obj.sync_play = sync_play
|
||||
if provider:
|
||||
import os
|
||||
|
||||
ctx.obj.provider = provider
|
||||
os.environ["CURRENT_FASTANIME_PROVIDER"] = provider
|
||||
if server:
|
||||
ctx.obj.server = server
|
||||
if format:
|
||||
ctx.obj.format = format
|
||||
if sub_lang:
|
||||
ctx.obj.sub_lang = sub_lang
|
||||
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.continue_from_history = continue_
|
||||
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.skip = skip
|
||||
if (
|
||||
ctx.get_parameter_source("normalize_titles")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.normalize_titles = normalize_titles
|
||||
|
||||
if quality:
|
||||
ctx.obj.quality = quality
|
||||
if ctx.get_parameter_source("auto_next") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.auto_next = auto_next
|
||||
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.icons = icons
|
||||
if (
|
||||
ctx.get_parameter_source("local_history")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.preferred_history = "local" if local_history else "remote"
|
||||
if (
|
||||
ctx.get_parameter_source("auto_select")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.auto_select = auto_select
|
||||
if (
|
||||
ctx.get_parameter_source("use_python_mpv")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.use_python_mpv = use_python_mpv
|
||||
if downloads_dir:
|
||||
ctx.obj.downloads_dir = downloads_dir
|
||||
if translation_type:
|
||||
ctx.obj.translation_type = translation_type
|
||||
if fzf:
|
||||
ctx.obj.use_fzf = True
|
||||
if default:
|
||||
ctx.obj.use_fzf = False
|
||||
if preview:
|
||||
ctx.obj.preview = True
|
||||
if no_preview:
|
||||
ctx.obj.preview = False
|
||||
if dub:
|
||||
ctx.obj.translation_type = "dub"
|
||||
if sub:
|
||||
ctx.obj.translation_type = "sub"
|
||||
if rofi:
|
||||
ctx.obj.use_fzf = False
|
||||
ctx.obj.use_rofi = True
|
||||
if rofi:
|
||||
from ..libs.rofi import Rofi
|
||||
|
||||
if rofi_theme:
|
||||
ctx.obj.rofi_theme = rofi_theme
|
||||
Rofi.rofi_theme = rofi_theme
|
||||
|
||||
if rofi_theme_input:
|
||||
ctx.obj.rofi_theme_input = rofi_theme_input
|
||||
Rofi.rofi_theme_input = rofi_theme_input
|
||||
|
||||
if rofi_theme_confirm:
|
||||
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
|
||||
Rofi.rofi_theme_confirm = rofi_theme_confirm
|
||||
@@ -1,52 +0,0 @@
|
||||
import click
|
||||
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
from .__lazyloader__ import LazyGroup
|
||||
|
||||
commands = {
|
||||
"trending": "trending.trending",
|
||||
"recent": "recent.recent",
|
||||
"search": "search.search",
|
||||
"upcoming": "upcoming.upcoming",
|
||||
"scores": "scores.scores",
|
||||
"popular": "popular.popular",
|
||||
"favourites": "favourites.favourites",
|
||||
"random": "random_anime.random_anime",
|
||||
"login": "login.login",
|
||||
"watching": "watching.watching",
|
||||
"paused": "paused.paused",
|
||||
"rewatching": "rewatching.rewatching",
|
||||
"dropped": "dropped.dropped",
|
||||
"completed": "completed.completed",
|
||||
"planning": "planning.planning",
|
||||
"notifier": "notifier.notifier",
|
||||
"stats": "stats.stats",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
lazy_subcommands=commands,
|
||||
cls=LazyGroup,
|
||||
invoke_without_command=True,
|
||||
help="A beautiful interface that gives you access to a commplete streaming experience",
|
||||
short_help="Access all streaming options",
|
||||
)
|
||||
@click.pass_context
|
||||
def anilist(ctx: click.Context):
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....AnimeProvider import AnimeProvider
|
||||
from ...interfaces.anilist_interfaces import (
|
||||
fastanime_main_menu as anilist_interface,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
config: Config = ctx.obj
|
||||
config.anime_provider = AnimeProvider(config.provider)
|
||||
if user := ctx.obj.user:
|
||||
AniList.update_login_info(user, user["token"])
|
||||
if ctx.invoked_subcommand is None:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
anilist_interface(ctx.obj, fastanime_runtime_state)
|
||||
@@ -1,42 +0,0 @@
|
||||
# in lazy_group.py
|
||||
import importlib
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class LazyGroup(click.Group):
|
||||
def __init__(self, *args, lazy_subcommands=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# lazy_subcommands is a map of the form:
|
||||
#
|
||||
# {command-name} -> {module-name}.{command-object-name}
|
||||
#
|
||||
self.lazy_subcommands = lazy_subcommands or {}
|
||||
|
||||
def list_commands(self, ctx):
|
||||
base = super().list_commands(ctx)
|
||||
lazy = sorted(self.lazy_subcommands.keys())
|
||||
return base + lazy
|
||||
|
||||
def get_command(self, ctx, cmd_name): # pyright:ignore
|
||||
if cmd_name in self.lazy_subcommands:
|
||||
return self._lazy_load(cmd_name)
|
||||
return super().get_command(ctx, cmd_name)
|
||||
|
||||
def _lazy_load(self, cmd_name: str):
|
||||
# lazily loading a command, first get the module name and attribute name
|
||||
import_path: str = self.lazy_subcommands[cmd_name]
|
||||
modname, cmd_object_name = import_path.rsplit(".", 1)
|
||||
# do the import
|
||||
mod = importlib.import_module(
|
||||
f".{modname}", package="fastanime.cli.commands.anilist"
|
||||
)
|
||||
# get the Command object from that module
|
||||
cmd_object = getattr(mod, cmd_object_name)
|
||||
# check the result to make debugging easier
|
||||
if not isinstance(cmd_object, click.BaseCommand):
|
||||
raise ValueError(
|
||||
f"Lazy loading of {import_path} failed by returning "
|
||||
"a non-command object"
|
||||
)
|
||||
return cmd_object
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you completed")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def completed(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("COMPLETED")
|
||||
if not anime_list or not anime_list[1]:
|
||||
return
|
||||
if not anime_list[0]:
|
||||
return
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.cli.config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you dropped")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def dropped(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("DROPPED")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,34 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most favourited anime from anilist",
|
||||
short_help="View most favourited anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def favourites(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_favourite()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,61 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Login to your anilist account")
|
||||
@click.option("--status", "-s", help="Whether you are logged in or not", is_flag=True)
|
||||
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
|
||||
@click.pass_obj
|
||||
def login(config: "Config", status, erase):
|
||||
from sys import exit
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm, Prompt
|
||||
|
||||
if status:
|
||||
is_logged_in = True if config.user else False
|
||||
message = (
|
||||
"You are logged in :smile:" if is_logged_in else "You arent logged in :cry:"
|
||||
)
|
||||
print(message)
|
||||
print(config.user)
|
||||
exit(0)
|
||||
elif erase:
|
||||
if Confirm.ask(
|
||||
"Are you sure you want to erase your login status", default=False
|
||||
):
|
||||
config.update_user({})
|
||||
print("Success")
|
||||
exit(0)
|
||||
else:
|
||||
exit(1)
|
||||
else:
|
||||
from click import launch
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if config.user:
|
||||
print("Already logged in :confused:")
|
||||
if not Confirm.ask("or would you like to reloggin", default=True):
|
||||
exit(0)
|
||||
# ---- new loggin -----
|
||||
print(
|
||||
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
|
||||
)
|
||||
launch(config.fastanime_anilist_app_login_url, wait=True)
|
||||
print("Please paste the token provided here")
|
||||
token = Prompt.ask("Enter token")
|
||||
user = AniList.login_user(token)
|
||||
if not user:
|
||||
print("Sth went wrong", user)
|
||||
exit(1)
|
||||
return
|
||||
user["token"] = token
|
||||
config.update_user(user)
|
||||
print("Successfully saved credentials")
|
||||
print(user)
|
||||
exit(0)
|
||||
@@ -1,125 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Check for notifications on anime you currently watching")
|
||||
@click.pass_obj
|
||||
def notifier(config: "Config"):
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from sys import exit
|
||||
|
||||
import requests
|
||||
from plyer import notification
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
notified = os.path.join(APP_DATA_DIR, "last_notification.json")
|
||||
anime_image_path = os.path.join(APP_CACHE_DIR, "notification_image")
|
||||
notification_duration = config.notification_duration * 60
|
||||
notification_image_path = ""
|
||||
|
||||
if not config.user:
|
||||
print("Not Authenticated")
|
||||
print("Run the following to get started: fastanime anilist loggin")
|
||||
exit(1)
|
||||
run = True
|
||||
# WARNING: Mess around with this value at your own risk
|
||||
timeout = 2 # time is in minutes
|
||||
if os.path.exists(notified):
|
||||
with open(notified, "r") as f:
|
||||
past_notifications = json.load(f)
|
||||
else:
|
||||
past_notifications = {}
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
|
||||
while run:
|
||||
try:
|
||||
logger.info("checking for notifications")
|
||||
result = AniList.get_notification()
|
||||
if not result[0]:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
data = result[1]
|
||||
if not data:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
|
||||
notifications = data["data"]["Page"]["notifications"]
|
||||
if not notifications:
|
||||
logger.info("Nothing to notify")
|
||||
else:
|
||||
for notification_ in notifications:
|
||||
anime_episode = notification_["episode"]
|
||||
anime_title = notification_["media"]["title"][
|
||||
config.preferred_language
|
||||
]
|
||||
title = f"{anime_title} Episode {anime_episode} just aired"
|
||||
# pyright:ignore
|
||||
message = "Be sure to watch so you are not left out of the loop."
|
||||
# message = str(textwrap.wrap(message, width=50))
|
||||
|
||||
id = notification_["media"]["id"]
|
||||
if past_notifications.get(str(id)) == notification_["episode"]:
|
||||
logger.info(
|
||||
f"skipping id={id} title={anime_title} episode={anime_episode} already notified"
|
||||
)
|
||||
|
||||
else:
|
||||
# windows only supports ico,
|
||||
# and you still ask why linux
|
||||
if PLATFORM != "Windows":
|
||||
image_link = notification_["media"]["coverImage"]["medium"]
|
||||
logger.info("Downloading image...")
|
||||
|
||||
resp = requests.get(image_link)
|
||||
if resp.status_code == 200:
|
||||
with open(anime_image_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
notification_image_path = anime_image_path
|
||||
else:
|
||||
logger.warn(
|
||||
f"Failed to get image response_status={resp.status_code} response_content={resp.content}"
|
||||
)
|
||||
notification_image_path = ICON_PATH
|
||||
else:
|
||||
notification_image_path = ICON_PATH
|
||||
|
||||
past_notifications[f"{id}"] = notification_["episode"]
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
logger.info(message)
|
||||
notification.notify( # pyright:ignore
|
||||
title=title,
|
||||
message=message,
|
||||
app_name=APP_NAME,
|
||||
app_icon=notification_image_path,
|
||||
hints={
|
||||
"image-path": notification_image_path,
|
||||
"desktop-entry": f"{APP_NAME}.desktop",
|
||||
},
|
||||
timeout=notification_duration,
|
||||
)
|
||||
time.sleep(30)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you paused on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def paused(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("PAUSED")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anilist_config = FastAnimeRuntimeState()
|
||||
anilist_config.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, anilist_config)
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you are planning on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def planning(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("PLANNING")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,33 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most popular anime", short_help="View most popular anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def popular(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_popular()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,39 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Get random anime from anilist based on a range of anilist anime ids that are seected at random",
|
||||
short_help="View random anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def random_anime(config, dump_json):
|
||||
import random
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
random_anime = range(1, 100000)
|
||||
|
||||
random_anime = random.sample(random_anime, k=50)
|
||||
|
||||
anime_data = AniList.search(id_in=list(random_anime))
|
||||
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
exit(1)
|
||||
@@ -1,34 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most recently updated anime from anilist that are currently releasing",
|
||||
short_help="View recently updated anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def recent(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_recently_updated()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you are rewatching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def rewatching(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("REPEATING")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,33 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most scored anime", short_help="View most scored anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scores(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_scored()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,574 +0,0 @@
|
||||
import click
|
||||
|
||||
from ...completion_functions import anime_titles_shell_complete
|
||||
|
||||
tags_available = {
|
||||
"Cast": ["Polyamorous"],
|
||||
"Cast Main Cast": [
|
||||
"Anti-Hero",
|
||||
"Elderly Protagonist",
|
||||
"Ensemble Cast",
|
||||
"Estranged Family",
|
||||
"Female Protagonist",
|
||||
"Male Protagonist",
|
||||
"Primarily Adult Cast",
|
||||
"Primarily Animal Cast",
|
||||
"Primarily Child Cast",
|
||||
"Primarily Female Cast",
|
||||
"Primarily Male Cast",
|
||||
"Primarily Teen Cast",
|
||||
],
|
||||
"Cast Traits": [
|
||||
"Age Regression",
|
||||
"Agender",
|
||||
"Aliens",
|
||||
"Amnesia",
|
||||
"Angels",
|
||||
"Anthropomorphism",
|
||||
"Aromantic",
|
||||
"Arranged Marriage",
|
||||
"Artificial Intelligence",
|
||||
"Asexual",
|
||||
"Butler",
|
||||
"Centaur",
|
||||
"Chimera",
|
||||
"Chuunibyou",
|
||||
"Clone",
|
||||
"Cosplay",
|
||||
"Cowboys",
|
||||
"Crossdressing",
|
||||
"Cyborg",
|
||||
"Delinquents",
|
||||
"Demons",
|
||||
"Detective",
|
||||
"Dinosaurs",
|
||||
"Disability",
|
||||
"Dissociative Identities",
|
||||
"Dragons",
|
||||
"Dullahan",
|
||||
"Elf",
|
||||
"Fairy",
|
||||
"Femboy",
|
||||
"Ghost",
|
||||
"Goblin",
|
||||
"Gods",
|
||||
"Gyaru",
|
||||
"Hikikomori",
|
||||
"Homeless",
|
||||
"Idol",
|
||||
"Kemonomimi",
|
||||
"Kuudere",
|
||||
"Maids",
|
||||
"Mermaid",
|
||||
"Monster Boy",
|
||||
"Monster Girl",
|
||||
"Nekomimi",
|
||||
"Ninja",
|
||||
"Nudity",
|
||||
"Nun",
|
||||
"Office Lady",
|
||||
"Oiran",
|
||||
"Ojou-sama",
|
||||
"Orphan",
|
||||
"Pirates",
|
||||
"Robots",
|
||||
"Samurai",
|
||||
"Shrine Maiden",
|
||||
"Skeleton",
|
||||
"Succubus",
|
||||
"Tanned Skin",
|
||||
"Teacher",
|
||||
"Tomboy",
|
||||
"Transgender",
|
||||
"Tsundere",
|
||||
"Twins",
|
||||
"Vampire",
|
||||
"Veterinarian",
|
||||
"Vikings",
|
||||
"Villainess",
|
||||
"VTuber",
|
||||
"Werewolf",
|
||||
"Witch",
|
||||
"Yandere",
|
||||
"Zombie",
|
||||
],
|
||||
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
|
||||
"Setting": ["Matriarchy"],
|
||||
"Setting Scene": [
|
||||
"Bar",
|
||||
"Boarding School",
|
||||
"Circus",
|
||||
"Coastal",
|
||||
"College",
|
||||
"Desert",
|
||||
"Dungeon",
|
||||
"Foreign",
|
||||
"Inn",
|
||||
"Konbini",
|
||||
"Natural Disaster",
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Prison",
|
||||
"Restaurant",
|
||||
"Rural",
|
||||
"School",
|
||||
"School Club",
|
||||
"Snowscape",
|
||||
"Urban",
|
||||
"Work",
|
||||
],
|
||||
"Setting Time": [
|
||||
"Achronological Order",
|
||||
"Anachronism",
|
||||
"Ancient China",
|
||||
"Dystopian",
|
||||
"Historical",
|
||||
"Time Skip",
|
||||
],
|
||||
"Setting Universe": [
|
||||
"Afterlife",
|
||||
"Alternate Universe",
|
||||
"Augmented Reality",
|
||||
"Omegaverse",
|
||||
"Post-Apocalyptic",
|
||||
"Space",
|
||||
"Urban Fantasy",
|
||||
"Virtual World",
|
||||
],
|
||||
"Technical": [
|
||||
"4-koma",
|
||||
"Achromatic",
|
||||
"Advertisement",
|
||||
"Anthology",
|
||||
"CGI",
|
||||
"Episodic",
|
||||
"Flash",
|
||||
"Full CGI",
|
||||
"Full Color",
|
||||
"No Dialogue",
|
||||
"Non-fiction",
|
||||
"POV",
|
||||
"Puppetry",
|
||||
"Rotoscoping",
|
||||
"Stop Motion",
|
||||
],
|
||||
"Theme Action": [
|
||||
"Archery",
|
||||
"Battle Royale",
|
||||
"Espionage",
|
||||
"Fugitive",
|
||||
"Guns",
|
||||
"Martial Arts",
|
||||
"Spearplay",
|
||||
"Swordplay",
|
||||
],
|
||||
"Theme Arts": [
|
||||
"Acting",
|
||||
"Calligraphy",
|
||||
"Classic Literature",
|
||||
"Drawing",
|
||||
"Fashion",
|
||||
"Food",
|
||||
"Makeup",
|
||||
"Photography",
|
||||
"Rakugo",
|
||||
"Writing",
|
||||
],
|
||||
"Theme Arts-Music": [
|
||||
"Band",
|
||||
"Classical Music",
|
||||
"Dancing",
|
||||
"Hip-hop Music",
|
||||
"Jazz Music",
|
||||
"Metal Music",
|
||||
"Musical Theater",
|
||||
"Rock Music",
|
||||
],
|
||||
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
|
||||
"Theme Drama": [
|
||||
"Bullying",
|
||||
"Class Struggle",
|
||||
"Coming of Age",
|
||||
"Conspiracy",
|
||||
"Eco-Horror",
|
||||
"Fake Relationship",
|
||||
"Kingdom Management",
|
||||
"Rehabilitation",
|
||||
"Revenge",
|
||||
"Suicide",
|
||||
"Tragedy",
|
||||
],
|
||||
"Theme Fantasy": [
|
||||
"Alchemy",
|
||||
"Body Swapping",
|
||||
"Cultivation",
|
||||
"Fairy Tale",
|
||||
"Henshin",
|
||||
"Isekai",
|
||||
"Kaiju",
|
||||
"Magic",
|
||||
"Mythology",
|
||||
"Necromancy",
|
||||
"Shapeshifting",
|
||||
"Steampunk",
|
||||
"Super Power",
|
||||
"Superhero",
|
||||
"Wuxia",
|
||||
"Youkai",
|
||||
],
|
||||
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
|
||||
"Theme Game-Card & Board Game": [
|
||||
"Card Battle",
|
||||
"Go",
|
||||
"Karuta",
|
||||
"Mahjong",
|
||||
"Poker",
|
||||
"Shogi",
|
||||
],
|
||||
"Theme Game-Sport": [
|
||||
"Acrobatics",
|
||||
"Airsoft",
|
||||
"American Football",
|
||||
"Athletics",
|
||||
"Badminton",
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Bowling",
|
||||
"Boxing",
|
||||
"Cheerleading",
|
||||
"Cycling",
|
||||
"Fencing",
|
||||
"Fishing",
|
||||
"Fitness",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Handball",
|
||||
"Ice Skating",
|
||||
"Judo",
|
||||
"Lacrosse",
|
||||
"Parkour",
|
||||
"Rugby",
|
||||
"Scuba Diving",
|
||||
"Skateboarding",
|
||||
"Sumo",
|
||||
"Surfing",
|
||||
"Swimming",
|
||||
"Table Tennis",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wrestling",
|
||||
],
|
||||
"Theme Other": [
|
||||
"Adoption",
|
||||
"Animals",
|
||||
"Astronomy",
|
||||
"Autobiographical",
|
||||
"Biographical",
|
||||
"Body Horror",
|
||||
"Cannibalism",
|
||||
"Chibi",
|
||||
"Cosmic Horror",
|
||||
"Crime",
|
||||
"Crossover",
|
||||
"Death Game",
|
||||
"Denpa",
|
||||
"Drugs",
|
||||
"Economics",
|
||||
"Educational",
|
||||
"Environmental",
|
||||
"Ero Guro",
|
||||
"Filmmaking",
|
||||
"Found Family",
|
||||
"Gambling",
|
||||
"Gender Bending",
|
||||
"Gore",
|
||||
"Language Barrier",
|
||||
"LGBTQ+ Themes",
|
||||
"Lost Civilization",
|
||||
"Marriage",
|
||||
"Medicine",
|
||||
"Memory Manipulation",
|
||||
"Meta",
|
||||
"Mountaineering",
|
||||
"Noir",
|
||||
"Otaku Culture",
|
||||
"Pandemic",
|
||||
"Philosophy",
|
||||
"Politics",
|
||||
"Proxy Battle",
|
||||
"Psychosexual",
|
||||
"Reincarnation",
|
||||
"Religion",
|
||||
"Royal Affairs",
|
||||
"Slavery",
|
||||
"Software Development",
|
||||
"Survival",
|
||||
"Terrorism",
|
||||
"Torture",
|
||||
"Travel",
|
||||
"War",
|
||||
],
|
||||
"Theme Other-Organisations": [
|
||||
"Assassins",
|
||||
"Criminal Organization",
|
||||
"Cult",
|
||||
"Firefighters",
|
||||
"Gangs",
|
||||
"Mafia",
|
||||
"Military",
|
||||
"Police",
|
||||
"Triads",
|
||||
"Yakuza",
|
||||
],
|
||||
"Theme Other-Vehicle": [
|
||||
"Aviation",
|
||||
"Cars",
|
||||
"Mopeds",
|
||||
"Motorcycles",
|
||||
"Ships",
|
||||
"Tanks",
|
||||
"Trains",
|
||||
],
|
||||
"Theme Romance": [
|
||||
"Age Gap",
|
||||
"Bisexual",
|
||||
"Boys' Love",
|
||||
"Female Harem",
|
||||
"Heterosexual",
|
||||
"Love Triangle",
|
||||
"Male Harem",
|
||||
"Matchmaking",
|
||||
"Mixed Gender Harem",
|
||||
"Teens' Love",
|
||||
"Unrequited Love",
|
||||
"Yuri",
|
||||
],
|
||||
"Theme Sci Fi": [
|
||||
"Cyberpunk",
|
||||
"Space Opera",
|
||||
"Time Loop",
|
||||
"Time Manipulation",
|
||||
"Tokusatsu",
|
||||
],
|
||||
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
|
||||
"Theme Slice of Life": [
|
||||
"Agriculture",
|
||||
"Cute Boys Doing Cute Things",
|
||||
"Cute Girls Doing Cute Things",
|
||||
"Family Life",
|
||||
"Horticulture",
|
||||
"Iyashikei",
|
||||
"Parenthood",
|
||||
],
|
||||
}
|
||||
tags_available_list = []
|
||||
for tag_category, tags_in_category in tags_available.items():
|
||||
tags_available_list.extend(tags_in_category)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(["WINTER", "SPRING", "SUMMER", "FALL"]),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(
|
||||
["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(
|
||||
[
|
||||
"ID",
|
||||
"ID_DESC",
|
||||
"TITLE_ROMAJI",
|
||||
"TITLE_ROMAJI_DESC",
|
||||
"TITLE_ENGLISH",
|
||||
"TITLE_ENGLISH_DESC",
|
||||
"TITLE_NATIVE",
|
||||
"TITLE_NATIVE_DESC",
|
||||
"TYPE",
|
||||
"TYPE_DESC",
|
||||
"FORMAT",
|
||||
"FORMAT_DESC",
|
||||
"START_DATE",
|
||||
"START_DATE_DESC",
|
||||
"END_DATE",
|
||||
"END_DATE_DESC",
|
||||
"SCORE",
|
||||
"SCORE_DESC",
|
||||
"POPULARITY",
|
||||
"POPULARITY_DESC",
|
||||
"TRENDING",
|
||||
"TRENDING_DESC",
|
||||
"EPISODES",
|
||||
"EPISODES_DESC",
|
||||
"DURATION",
|
||||
"DURATION_DESC",
|
||||
"STATUS",
|
||||
"STATUS_DESC",
|
||||
"CHAPTERS",
|
||||
"CHAPTERS_DESC",
|
||||
"VOLUMES",
|
||||
"VOLUMES_DESC",
|
||||
"UPDATED_AT",
|
||||
"UPDATED_AT_DESC",
|
||||
"SEARCH_MATCH",
|
||||
"FAVOURITES",
|
||||
"FAVOURITES_DESC",
|
||||
]
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(
|
||||
[
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Horror",
|
||||
"Mahou Shoujo",
|
||||
"Mecha",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"Sci-Fi",
|
||||
"Slice of Life",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Thriller",
|
||||
"Hentai",
|
||||
]
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(
|
||||
["TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "MUSIC", "NOVEL", "ONE_SHOT"]
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(
|
||||
[
|
||||
"1900",
|
||||
"1910",
|
||||
"1920",
|
||||
"1930",
|
||||
"1940",
|
||||
"1950",
|
||||
"1960",
|
||||
"1970",
|
||||
"1980",
|
||||
"1990",
|
||||
"2000",
|
||||
"2004",
|
||||
"2005",
|
||||
"2006",
|
||||
"2007",
|
||||
"2008",
|
||||
"2009",
|
||||
"2010",
|
||||
"2011",
|
||||
"2012",
|
||||
"2013",
|
||||
"2014",
|
||||
"2015",
|
||||
"2016",
|
||||
"2017",
|
||||
"2018",
|
||||
"2019",
|
||||
"2020",
|
||||
"2021",
|
||||
"2022",
|
||||
"2023",
|
||||
"2024",
|
||||
]
|
||||
),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(
|
||||
config,
|
||||
title,
|
||||
dump_json,
|
||||
season,
|
||||
status,
|
||||
sort,
|
||||
genres,
|
||||
tags,
|
||||
media_format,
|
||||
year,
|
||||
on_list,
|
||||
):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, search_results = AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
)
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(search_results))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = search_results
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,63 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Print out your anilist stats")
|
||||
@click.pass_obj
|
||||
def stats(
|
||||
config: "Config",
|
||||
):
|
||||
import shutil
|
||||
import subprocess
|
||||
from sys import exit
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
user_data = AniList.get_user_info()
|
||||
if not user_data[0] or not user_data[1]:
|
||||
print("Failed to get user info")
|
||||
print(user_data[1])
|
||||
exit(1)
|
||||
|
||||
KITTEN_EXECUTABLE = shutil.which("kitten")
|
||||
if not KITTEN_EXECUTABLE:
|
||||
print("Kitten not found")
|
||||
exit(1)
|
||||
|
||||
image_url = user_data[1]["data"]["User"]["avatar"]["medium"]
|
||||
user_name = user_data[1]["data"]["User"]["name"]
|
||||
about = user_data[1]["data"]["User"]["about"] or ""
|
||||
console.clear()
|
||||
image_x = int(console.size.width * 0.1)
|
||||
image_y = int(console.size.height * 0.1)
|
||||
img_w = console.size.width // 3
|
||||
img_h = console.size.height // 3
|
||||
image_process = subprocess.run(
|
||||
[
|
||||
KITTEN_EXECUTABLE,
|
||||
"icat",
|
||||
"--clear",
|
||||
"--place",
|
||||
f"{img_w}x{img_h}@{image_x}x{image_y}",
|
||||
image_url,
|
||||
],
|
||||
)
|
||||
if not image_process.returncode == 0:
|
||||
print("failed to get image from icat")
|
||||
exit(1)
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(about),
|
||||
title=user_name,
|
||||
)
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 anime that are currently trending",
|
||||
short_help="Trending anime 🔥🔥🔥",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def trending(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, data = AniList.get_trending()
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,33 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most anticipited anime", short_help="View upcoming anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def upcoming(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, data = AniList.get_upcoming_anime()
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,46 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you are watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def watching(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("CURRENT")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,39 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(help="Helper command to manage cache")
|
||||
@click.option("--clean", help="Clean the cache dir", is_flag=True)
|
||||
@click.option("--path", help="The path to the cache dir", is_flag=True)
|
||||
@click.option("--size", help="The size of the cache dir", is_flag=True)
|
||||
def cache(clean, path, size):
|
||||
from ...constants import APP_CACHE_DIR
|
||||
|
||||
if path:
|
||||
print(APP_CACHE_DIR)
|
||||
elif clean:
|
||||
import shutil
|
||||
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask(
|
||||
f"Are you sure you want to clean the following path: {APP_CACHE_DIR};(NOTE: !!The action is irreversible and will clean your cache!!)",
|
||||
default=False,
|
||||
):
|
||||
print("Cleaning...")
|
||||
shutil.rmtree(APP_CACHE_DIR)
|
||||
print("Successfully removed: ", APP_CACHE_DIR)
|
||||
elif size:
|
||||
import os
|
||||
|
||||
from ..utils.utils import format_bytes_to_human
|
||||
|
||||
total_size = 0
|
||||
for dirpath, dirnames, filenames in os.walk(APP_CACHE_DIR):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
total_size += os.path.getsize(fp)
|
||||
print("Total Size: ", format_bytes_to_human(total_size))
|
||||
else:
|
||||
import click
|
||||
|
||||
click.launch(APP_CACHE_DIR)
|
||||
@@ -1,101 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Manage your config with ease",
|
||||
short_help="Edit your config",
|
||||
)
|
||||
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--view", "-v", help="View the current contents of your config", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--desktop-entry",
|
||||
"-d",
|
||||
help="Configure the desktop entry of fastanime",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--update",
|
||||
"-u",
|
||||
help="Persist all the config options passed to fastanime to your config file",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def config(user_config: "Config", path, view, desktop_entry, update):
|
||||
import sys
|
||||
|
||||
from rich import print
|
||||
|
||||
from ... import __version__
|
||||
from ...constants import APP_NAME, ICON_PATH, S_PLATFORM, USER_CONFIG_PATH
|
||||
|
||||
if path:
|
||||
print(USER_CONFIG_PATH)
|
||||
elif view:
|
||||
print(user_config)
|
||||
elif desktop_entry:
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from ..utils.tools import exit_app
|
||||
|
||||
FASTANIME_EXECUTABLE = shutil.which("fastanime")
|
||||
if FASTANIME_EXECUTABLE:
|
||||
cmds = f"{FASTANIME_EXECUTABLE} --rofi anilist"
|
||||
else:
|
||||
cmds = f"{sys.executable} -m fastanime --rofi anilist"
|
||||
|
||||
# TODO: Get funs of the other platforms to complete this lol
|
||||
if S_PLATFORM == "win32":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of windows to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
elif S_PLATFORM == "darwin":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of mac to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
else:
|
||||
desktop_entry = dedent(
|
||||
f"""
|
||||
[Desktop Entry]
|
||||
Name={APP_NAME}
|
||||
Type=Application
|
||||
version={__version__}
|
||||
Path={Path().home()}
|
||||
Comment=Watch anime from your terminal
|
||||
Terminal=false
|
||||
Icon={ICON_PATH}
|
||||
Exec={cmds}
|
||||
Categories=Entertainment
|
||||
"""
|
||||
)
|
||||
base = os.path.expanduser("~/.local/share/applications")
|
||||
desktop_entry_path = os.path.join(base, f"{APP_NAME}.desktop")
|
||||
if os.path.exists(desktop_entry_path):
|
||||
if not Confirm.ask(
|
||||
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
|
||||
default=False,
|
||||
):
|
||||
exit_app(1)
|
||||
with open(desktop_entry_path, "w") as f:
|
||||
f.write(desktop_entry)
|
||||
with open(desktop_entry_path) as f:
|
||||
print(f"Successfully wrote \n{f.read()}")
|
||||
exit_app(0)
|
||||
elif update:
|
||||
with open(USER_CONFIG_PATH, "w") as file:
|
||||
file.write(user_config.__repr__())
|
||||
print("update successfull")
|
||||
else:
|
||||
click.edit(filename=USER_CONFIG_PATH)
|
||||
@@ -1,332 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Download anime using the anime provider for a specified range",
|
||||
short_help="Download anime",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--file",
|
||||
"-f",
|
||||
type=click.File(),
|
||||
help="A file to read from all anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-F",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.option(
|
||||
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="After merging delete the original files",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-time",
|
||||
"-w",
|
||||
type=int,
|
||||
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
|
||||
default=60,
|
||||
)
|
||||
@click.option(
|
||||
"--prompt/--no-prompt",
|
||||
help="Whether to prompt for anything instead just do the best thing",
|
||||
default=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
):
|
||||
import time
|
||||
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...libs.fzf import fzf
|
||||
from ...Utility.data import anime_normalizer
|
||||
from ...Utility.downloader.downloader import downloader
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import (
|
||||
filter_by_quality,
|
||||
fuzzy_inquirer,
|
||||
move_preferred_subtitle_lang_to_top,
|
||||
)
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
translation_type = config.translation_type
|
||||
download_dir = config.downloads_dir
|
||||
if file:
|
||||
contents = file.read()
|
||||
anime_titles_from_file = tuple(
|
||||
[title for title in contents.split("\n") if title]
|
||||
)
|
||||
file.close()
|
||||
|
||||
anime_titles = (*anime_titles_from_file, *anime_titles)
|
||||
print(f"[green bold]Queued:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
if anime_title == "EOF":
|
||||
break
|
||||
print(f"[green bold]Now Downloading: [/] {anime_title}")
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results failed")
|
||||
input("Enter to retry")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Nothing muches your search term")
|
||||
continue
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
selected_anime_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto selecting:[/] ", selected_anime_title)
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
selected_anime_title = fzf.run(
|
||||
choices, "Please Select title: ", "FastAnime"
|
||||
)
|
||||
else:
|
||||
selected_anime_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select title",
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[selected_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
)
|
||||
return
|
||||
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
print(f"[green bold]Downloading: [/] {episodes_range}")
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if config.normalize_titles:
|
||||
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
|
||||
|
||||
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
episode = str(episode)
|
||||
if episode not in episodes:
|
||||
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
|
||||
continue
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("No streams skipping")
|
||||
continue
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server_name = next(streams, None)
|
||||
if not server_name:
|
||||
print("Sth went wrong when fetching the server")
|
||||
continue
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, server_name["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = server_name["headers"]
|
||||
episode_title = server_name["episode_title"]
|
||||
subtitles = server_name["subtitles"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server_name = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server_name = fzf.run(servers_names, "Select an link: ")
|
||||
else:
|
||||
server_name = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server_name]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = servers[server_name]["headers"]
|
||||
|
||||
subtitles = servers[server_name]["subtitles"]
|
||||
episode_title = servers[server_name]["episode_title"]
|
||||
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["episodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(f"[purple]Now Downloading:[/] {episode_title}")
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
downloader._download_file(
|
||||
link,
|
||||
selected_anime_title,
|
||||
episode_title,
|
||||
download_dir,
|
||||
silent,
|
||||
config.format,
|
||||
force_unknown_ext,
|
||||
verbose,
|
||||
headers=provider_headers,
|
||||
sub=subtitles[0]["url"] if subtitles else "",
|
||||
merge=merge,
|
||||
clean=clean,
|
||||
prompt=prompt,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
print("Done Downloading")
|
||||
time.sleep(wait_time)
|
||||
exit_app()
|
||||
@@ -1,327 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv", short_help="Watch downloads"
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ...cli.utils.mpv import run_mpv
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ...Utility.utils import sort_by_episode_number
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
return
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import fzf_preview
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for anime_title in anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import fzf_preview
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name: ",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(episode_path)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name: ",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(playlist)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
@@ -1,208 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to get streams for anime to use externally in a non-python application",
|
||||
short_help="Print anime streams to standard out",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--search-results-only",
|
||||
"-s",
|
||||
help="print only the search results to stdout",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--episode-streams-only",
|
||||
"-e",
|
||||
help="print only selected anime episodes streams of given range",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def grab(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
search_results_only,
|
||||
anime_info_only,
|
||||
episode_streams_only,
|
||||
):
|
||||
import json
|
||||
from logging import getLogger
|
||||
from sys import exit
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
logger = getLogger(__name__)
|
||||
if config.manga:
|
||||
manga_title = anime_titles[0]
|
||||
from ...MangaProvider import MangaProvider
|
||||
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
print(json.dumps(search_data))
|
||||
exit(0)
|
||||
search_results = search_data["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_titles[0])
|
||||
)
|
||||
manga_info = manga_provider.get_manga(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not manga_info:
|
||||
return
|
||||
if anime_info_only:
|
||||
print(json.dumps(manga_info))
|
||||
exit(0)
|
||||
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(episode_range)
|
||||
)
|
||||
if not chapter_info:
|
||||
exit(1)
|
||||
print(json.dumps(chapter_info))
|
||||
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
|
||||
grabbed_animes = []
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
# grab only search results skipping all lines after this
|
||||
grabbed_animes.append(search_results)
|
||||
continue
|
||||
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
anime = anime_provider.get_anime(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
exit(1)
|
||||
if anime_info_only:
|
||||
# grab only the anime data skipping all lines after this
|
||||
grabbed_animes.append(anime)
|
||||
continue
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if not episode_streams_only:
|
||||
grabbed_anime = dict(anime)
|
||||
grabbed_anime["requested_episodes"] = episodes_range
|
||||
grabbed_anime["translation_type"] = config.translation_type
|
||||
grabbed_anime["episodes_streams"] = {}
|
||||
else:
|
||||
grabbed_anime = {}
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
if episode not in episodes:
|
||||
continue
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
continue
|
||||
episode_streams = {server["server"]: server for server in streams}
|
||||
|
||||
if episode_streams_only:
|
||||
grabbed_anime[episode] = episode_streams
|
||||
else:
|
||||
grabbed_anime["episodes_streams"][ # pyright:ignore
|
||||
episode
|
||||
] = episode_streams
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
# grab the full data for single title and appen to final result or episode streams
|
||||
grabbed_animes.append(grabbed_anime)
|
||||
|
||||
# print out the final result either {} or [] depending if more than one title os requested
|
||||
if len(grabbed_animes) == 1:
|
||||
print(json.dumps(grabbed_animes[0]))
|
||||
else:
|
||||
print(json.dumps(grabbed_animes))
|
||||
@@ -1,361 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...cli.config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
|
||||
short_help="Binge anime",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to binge (start-end)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(config: "Config", anime_titles: str, episode_range: str):
|
||||
from click import clear
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
if config.manga:
|
||||
from InquirerPy.prompts.number import NumberPrompt
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
from ...MangaProvider import MangaProvider
|
||||
from ..utils.feh import feh_manga_viewer
|
||||
|
||||
manga_title = anime_titles[0]
|
||||
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
print("No search results")
|
||||
exit(1)
|
||||
|
||||
search_results = search_data["results"]
|
||||
|
||||
search_results_ = {
|
||||
sanitize_filename(search_result["title"]): search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(title, manga_title),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
preview = None
|
||||
if config.preview:
|
||||
from ..interfaces.utils import get_fzf_manga_preview
|
||||
|
||||
preview = get_fzf_manga_preview(search_results)
|
||||
if config.use_fzf:
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title: ", preview=preview
|
||||
)
|
||||
elif config.use_rofi:
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
anilist_id = search_results_[search_result_manga_title]["id"]
|
||||
manga_info = manga_provider.get_manga(anilist_id)
|
||||
if not manga_info:
|
||||
print("No manga info")
|
||||
exit(1)
|
||||
|
||||
anilist_helper = None
|
||||
if config.user:
|
||||
from ...anilist import AniList
|
||||
|
||||
AniList.login_user(config.user["token"])
|
||||
anilist_helper = AniList
|
||||
|
||||
def _manga_viewer():
|
||||
chapter_number = NumberPrompt("Select a chapter number").execute()
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(chapter_number)
|
||||
)
|
||||
|
||||
if not chapter_info:
|
||||
print("No chapter info")
|
||||
input("Enter to retry...")
|
||||
_manga_viewer()
|
||||
return
|
||||
print(
|
||||
f"[purple bold]Now Reading: [/] {search_result_manga_title} [cyan bold]Chapter:[/] {chapter_info['title']}"
|
||||
)
|
||||
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
|
||||
if anilist_helper:
|
||||
anilist_helper.update_anime_list(
|
||||
{"mediaId": anilist_id, "progress": chapter_number}
|
||||
)
|
||||
_manga_viewer()
|
||||
|
||||
_manga_viewer()
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...Utility.data import anime_normalizer
|
||||
from ..utils.mpv import run_mpv
|
||||
from ..utils.utils import filter_by_quality, move_preferred_subtitle_lang_to_top
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
print(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results not found")
|
||||
input("Enter to retry")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Anime not found :cry:")
|
||||
exit_app()
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title: ", "FastAnime"
|
||||
)
|
||||
elif config.use_rofi:
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[search_result_manga_title]["id"]
|
||||
)
|
||||
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
if config.normalize_titles:
|
||||
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
|
||||
|
||||
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
|
||||
|
||||
def stream_anime():
|
||||
clear()
|
||||
episode = None
|
||||
|
||||
if episodes_range:
|
||||
try:
|
||||
episode = next(episodes_range) # pyright:ignore
|
||||
print(
|
||||
f"[cyan]Auto selecting:[/] {search_result_manga_title} [cyan]Episode:[/] {episode}"
|
||||
)
|
||||
except StopIteration:
|
||||
print("[green]Completed binge sequence[/]:smile:")
|
||||
return
|
||||
|
||||
if not episode or episode not in episodes:
|
||||
choices = [*episodes, "end"]
|
||||
if config.use_fzf:
|
||||
episode = fzf.run(
|
||||
choices,
|
||||
"Select an episode: ",
|
||||
header=search_result_manga_title,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode = Rofi.run(choices, "Select an episode")
|
||||
else:
|
||||
episode = fuzzy_inquirer(
|
||||
choices,
|
||||
"Select episode",
|
||||
)
|
||||
if episode == "end":
|
||||
return
|
||||
|
||||
# ---- fetch streams ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("Failed to get streams")
|
||||
return
|
||||
|
||||
try:
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
print("Sth went wrong when fetching the episode")
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
return
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
return
|
||||
link = stream_link["link"]
|
||||
subtitles = server["subtitles"]
|
||||
stream_headers = server["headers"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server = fzf.run(servers_names, "Select an link: ")
|
||||
elif config.use_rofi:
|
||||
server = Rofi.run(servers_names, "Select an link")
|
||||
else:
|
||||
server = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
return
|
||||
link = stream_link["link"]
|
||||
stream_headers = servers[server]["headers"]
|
||||
subtitles = servers[server]["subtitles"]
|
||||
episode_title = servers[server]["episode_title"]
|
||||
|
||||
selected_anime_title = search_result_manga_title
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["episodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(
|
||||
f"[purple]Now Playing:[/] {selected_anime_title} Episode {episode}"
|
||||
)
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
)
|
||||
else:
|
||||
run_mpv(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
)
|
||||
except IndexError as e:
|
||||
print(e)
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
|
||||
stream_anime()
|
||||
@@ -1,42 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(help="Helper command to update fastanime to latest")
|
||||
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
|
||||
def update(
|
||||
check,
|
||||
):
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ... import __version__
|
||||
from ..app_updater import check_for_updates, update_app
|
||||
|
||||
def _print_release(release_data):
|
||||
console = Console()
|
||||
body = Markdown(release_data["body"])
|
||||
tag = github_release_data["tag_name"]
|
||||
tag_title = release_data["name"]
|
||||
github_page_url = release_data["html_url"]
|
||||
console.print(f"Release Page: {github_page_url}")
|
||||
console.print(f"Tag: {tag}")
|
||||
console.print(f"Title: {tag_title}")
|
||||
console.print(body)
|
||||
|
||||
if check:
|
||||
is_latest, github_release_data = check_for_updates()
|
||||
if not is_latest:
|
||||
print(
|
||||
f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
|
||||
)
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
print(f"You are running the latest version ({__version__}) of fastanime")
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
success, github_release_data = update_app()
|
||||
_print_release(github_release_data)
|
||||
if success:
|
||||
print("Successfully updated")
|
||||
else:
|
||||
print("failed to update")
|
||||
@@ -1,426 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from configparser import ConfigParser
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
|
||||
from ..libs.rofi import Rofi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..AnimeProvider import AnimeProvider
|
||||
|
||||
|
||||
class Config(object):
|
||||
manga = False
|
||||
sync_play = False
|
||||
anime_list: list
|
||||
watch_history: dict
|
||||
fastanime_anilist_app_login_url = (
|
||||
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
|
||||
)
|
||||
anime_provider: "AnimeProvider"
|
||||
user_data = {"watch_history": {}, "animelist": [], "user": {}}
|
||||
default_options = {
|
||||
"quality": "1080",
|
||||
"auto_next": "False",
|
||||
"auto_select": "True",
|
||||
"sort_by": "search match",
|
||||
"downloads_dir": USER_VIDEOS_DIR,
|
||||
"translation_type": "sub",
|
||||
"server": "top",
|
||||
"continue_from_history": "True",
|
||||
"preferred_history": "local",
|
||||
"use_python_mpv": "false",
|
||||
"force_window": "immediate",
|
||||
"preferred_language": "english",
|
||||
"use_fzf": "False",
|
||||
"preview": "False",
|
||||
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
|
||||
"provider": "allanime",
|
||||
"error": "3",
|
||||
"icons": "false",
|
||||
"notification_duration": "2",
|
||||
"skip": "false",
|
||||
"use_rofi": "false",
|
||||
"rofi_theme": "",
|
||||
"rofi_theme_input": "",
|
||||
"rofi_theme_confirm": "",
|
||||
"ffmpegthumnailer_seek_time": "-1",
|
||||
"sub_lang": "eng",
|
||||
"normalize_titles": "true",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.initialize_user_data()
|
||||
self.load_config()
|
||||
|
||||
def load_config(self):
|
||||
self.configparser = ConfigParser(self.default_options)
|
||||
self.configparser.add_section("stream")
|
||||
self.configparser.add_section("general")
|
||||
self.configparser.add_section("anilist")
|
||||
|
||||
# --- set config values from file or using defaults ---
|
||||
if os.path.exists(USER_CONFIG_PATH):
|
||||
self.configparser.read(USER_CONFIG_PATH)
|
||||
|
||||
self.downloads_dir = self.get_downloads_dir()
|
||||
self.sub_lang = self.get_sub_lang()
|
||||
self.provider = self.get_provider()
|
||||
self.use_fzf = self.get_use_fzf()
|
||||
self.use_rofi = self.get_use_rofi()
|
||||
self.skip = self.get_skip()
|
||||
self.icons = self.get_icons()
|
||||
self.preview = self.get_preview()
|
||||
self.translation_type = self.get_translation_type()
|
||||
self.sort_by = self.get_sort_by()
|
||||
self.continue_from_history = self.get_continue_from_history()
|
||||
self.auto_next = self.get_auto_next()
|
||||
self.normalize_titles = self.get_normalize_titles()
|
||||
self.auto_select = self.get_auto_select()
|
||||
self.use_python_mpv = self.get_use_mpv_mod()
|
||||
self.quality = self.get_quality()
|
||||
self.notification_duration = self.get_notification_duration()
|
||||
self.error = self.get_error()
|
||||
self.server = self.get_server()
|
||||
self.format = self.get_format()
|
||||
self.force_window = self.get_force_window()
|
||||
self.preferred_language = self.get_preferred_language()
|
||||
self.preferred_history = self.get_preferred_history()
|
||||
self.rofi_theme = self.get_rofi_theme()
|
||||
Rofi.rofi_theme = self.rofi_theme
|
||||
self.rofi_theme_input = self.get_rofi_theme_input()
|
||||
Rofi.rofi_theme_input = self.rofi_theme_input
|
||||
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
|
||||
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
|
||||
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
|
||||
# ---- setup user data ------
|
||||
self.watch_history: dict = self.user_data.get("watch_history", {})
|
||||
self.anime_list: list = self.user_data.get("animelist", [])
|
||||
self.user: dict = self.user_data.get("user", {})
|
||||
|
||||
os.environ["CURRENT_FASTANIME_PROVIDER"] = self.provider
|
||||
if not os.path.exists(USER_CONFIG_PATH):
|
||||
with open(USER_CONFIG_PATH, "w") as config:
|
||||
config.write(self.__repr__())
|
||||
|
||||
def update_user(self, user):
|
||||
self.user = user
|
||||
self.user_data["user"] = user
|
||||
self._update_user_data()
|
||||
|
||||
def update_watch_history(
|
||||
self, anime_id: int, episode: str, start_time="0", total_time="0"
|
||||
):
|
||||
self.watch_history.update(
|
||||
{
|
||||
str(anime_id): {
|
||||
"episode": episode,
|
||||
"start_time": start_time,
|
||||
"total_time": total_time,
|
||||
}
|
||||
}
|
||||
)
|
||||
self.user_data["watch_history"] = self.watch_history
|
||||
self._update_user_data()
|
||||
|
||||
def initialize_user_data(self):
|
||||
try:
|
||||
if os.path.isfile(USER_DATA_PATH):
|
||||
with open(USER_DATA_PATH, "r") as f:
|
||||
user_data = json.load(f)
|
||||
self.user_data.update(user_data)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def _update_user_data(self):
|
||||
"""method that updates the actual user data file"""
|
||||
with open(USER_DATA_PATH, "w") as f:
|
||||
json.dump(self.user_data, f)
|
||||
|
||||
# getters for user configuration
|
||||
|
||||
# --- general section ---
|
||||
def get_provider(self):
|
||||
return self.configparser.get("general", "provider")
|
||||
|
||||
def get_ffmpegthumnailer_seek_time(self):
|
||||
return self.configparser.getint("general", "ffmpegthumnailer_seek_time")
|
||||
|
||||
def get_preferred_language(self):
|
||||
return self.configparser.get("general", "preferred_language")
|
||||
|
||||
def get_sub_lang(self):
|
||||
return self.configparser.get("general", "sub_lang")
|
||||
|
||||
def get_downloads_dir(self):
|
||||
return self.configparser.get("general", "downloads_dir")
|
||||
|
||||
def get_icons(self):
|
||||
return self.configparser.getboolean("general", "icons")
|
||||
|
||||
def get_preview(self):
|
||||
return self.configparser.getboolean("general", "preview")
|
||||
|
||||
def get_use_fzf(self):
|
||||
return self.configparser.getboolean("general", "use_fzf")
|
||||
|
||||
# rofi conifiguration
|
||||
def get_use_rofi(self):
|
||||
return self.configparser.getboolean("general", "use_rofi")
|
||||
|
||||
def get_rofi_theme(self):
|
||||
return self.configparser.get("general", "rofi_theme")
|
||||
|
||||
def get_rofi_theme_input(self):
|
||||
return self.configparser.get("general", "rofi_theme_input")
|
||||
|
||||
def get_rofi_theme_confirm(self):
|
||||
return self.configparser.get("general", "rofi_theme_confirm")
|
||||
|
||||
def get_normalize_titles(self):
|
||||
return self.configparser.getboolean("general", "normalize_titles")
|
||||
|
||||
# --- stream section ---
|
||||
def get_skip(self):
|
||||
return self.configparser.getboolean("stream", "skip")
|
||||
|
||||
def get_auto_next(self):
|
||||
return self.configparser.getboolean("stream", "auto_next")
|
||||
|
||||
def get_auto_select(self):
|
||||
return self.configparser.getboolean("stream", "auto_select")
|
||||
|
||||
def get_continue_from_history(self):
|
||||
return self.configparser.getboolean("stream", "continue_from_history")
|
||||
|
||||
def get_use_mpv_mod(self):
|
||||
return self.configparser.getboolean("stream", "use_python_mpv")
|
||||
|
||||
def get_notification_duration(self):
|
||||
return self.configparser.getint("general", "notification_duration")
|
||||
|
||||
def get_error(self):
|
||||
return self.configparser.getint("stream", "error")
|
||||
|
||||
def get_force_window(self):
|
||||
return self.configparser.get("stream", "force_window")
|
||||
|
||||
def get_translation_type(self):
|
||||
return self.configparser.get("stream", "translation_type")
|
||||
|
||||
def get_preferred_history(self):
|
||||
return self.configparser.get("stream", "preferred_history")
|
||||
|
||||
def get_quality(self):
|
||||
return self.configparser.get("stream", "quality")
|
||||
|
||||
def get_server(self):
|
||||
return self.configparser.get("stream", "server")
|
||||
|
||||
def get_format(self):
|
||||
return self.configparser.get("stream", "format")
|
||||
|
||||
def get_sort_by(self):
|
||||
return self.configparser.get("anilist", "sort_by")
|
||||
|
||||
def update_config(self, section: str, key: str, value: str):
|
||||
self.configparser.set(section, key, value)
|
||||
with open(USER_CONFIG_PATH, "w") as config:
|
||||
self.configparser.write(config)
|
||||
|
||||
def __repr__(self):
|
||||
current_config_state = f"""\
|
||||
#
|
||||
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
|
||||
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
|
||||
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
|
||||
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
|
||||
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
|
||||
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
|
||||
#
|
||||
[general]
|
||||
# whether to show the icons in the tui [True/False]
|
||||
# more like emojis
|
||||
# by the way if you have any recommendations to which should be used where please
|
||||
# don't hesitate to share your opinion
|
||||
# cause it's a lot of work to look for the right one for each menu option
|
||||
# be sure to also give the replacement emoji
|
||||
icons = {self.icons}
|
||||
|
||||
# the quality of the stream [1080,720,480,360]
|
||||
# this option is usually only reliable when:
|
||||
# provider=animepahe
|
||||
# since it provides links that actually point to streams of different qualities
|
||||
# while the rest just point to another link that can provide the anime from the same server
|
||||
quality = {self.quality}
|
||||
|
||||
# whether to normalize provider titles [True/False]
|
||||
# basically takes the provider titles and finds the corresponding anilist title then changes the title to that
|
||||
# useful for uniformity especially when downloading from different providers
|
||||
# this also applies to episode titles
|
||||
normalize_titles = {self.normalize_titles}
|
||||
|
||||
# can be [allanime, animepahe, aniwatch]
|
||||
# allanime is the most realible
|
||||
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
|
||||
# aniwatch which is now hianime usually provides subs in different languuages and its servers are generally faster
|
||||
provider = {self.provider}
|
||||
|
||||
# Display language [english, romaji]
|
||||
# this is passed to anilist directly and is used to set the language which the anime titles will be in
|
||||
# when using the anilist interface
|
||||
preferred_language = {self.preferred_language}
|
||||
|
||||
# Download directory
|
||||
# where you will find your videos after downloading them with 'fastanime download' command
|
||||
downloads_dir = {self.downloads_dir}
|
||||
|
||||
# whether to show a preview window when using fzf or rofi [True/False]
|
||||
# the preview requires you have a commandline image viewer as documented in the README
|
||||
# this is only when usinf fzf
|
||||
# if you dont care about image previews it doesnt matter
|
||||
# though its awesome
|
||||
# try it and you will see
|
||||
preview = {self.preview}
|
||||
|
||||
# the time to seek when using ffmpegthumbnailer [-1 to 100]
|
||||
# -1 means random and is the default
|
||||
# ffmpegthumbnailer is used to generate previews and you can select at what time in the video to extract an image
|
||||
# random makes things quite exciting cause you never no at what time it will extract the image from
|
||||
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
|
||||
|
||||
# whether to use fzf as the interface for the anilist command and others. [True/False]
|
||||
use_fzf = {self.use_fzf}
|
||||
|
||||
# whether to use rofi for the ui [True/False]
|
||||
# it's more useful if you want to create a desktop entry
|
||||
# which can be setup with 'fastanime config --desktop-entry'
|
||||
# though if you want it to be your sole interface even when fastanime is run directly from the terminal
|
||||
use_rofi = {self.use_rofi}
|
||||
|
||||
# rofi themes to use
|
||||
# the values of this option is the path to the rofi config files to use
|
||||
# i choose to split it into three since it gives the best look and feel
|
||||
# you can refer to the rofi demo on github to see for your self
|
||||
# by the way i recommend getting the rofi themes from this project;
|
||||
rofi_theme = {self.rofi_theme}
|
||||
|
||||
rofi_theme_input = {self.rofi_theme_input}
|
||||
|
||||
rofi_theme_confirm = {self.rofi_theme_confirm}
|
||||
|
||||
# the duration in minutes a notification will stay in the screen
|
||||
# used by notifier command
|
||||
notification_duration = {self.notification_duration}
|
||||
|
||||
# used when the provider gives subs of different languages
|
||||
# currently its the case for:
|
||||
# aniwatch
|
||||
# the values for this option are the short names for countries
|
||||
# regex is used to determine what you selected
|
||||
sub_lang = {self.sub_lang}
|
||||
|
||||
|
||||
[stream]
|
||||
# Auto continue from watch history [True/False]
|
||||
# this will make fastanime to choose the episode that you last watched to completion
|
||||
# and increment it by one
|
||||
# and use that to auto select the episode you want to watch
|
||||
continue_from_history = {self.continue_from_history}
|
||||
|
||||
# which history to use [local/remote]
|
||||
# local history means it will just use the watch history stored locally in your device
|
||||
# the file that stores it is called watch_history.json and is stored next to your config file
|
||||
# remote means it ignores the last episode stored locally and instead uses the one in your anilist anime list
|
||||
# this config option is useful if you want to overwrite your local history or import history covered from another device or platform
|
||||
# since remote history will take precendence over whats available locally
|
||||
preferred_history = {self.preferred_history}
|
||||
|
||||
# Preferred language for anime [dub/sub]
|
||||
translation_type = {self.translation_type}
|
||||
|
||||
# what server to use for a particular provider
|
||||
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
|
||||
# animepahe: [kwik]
|
||||
# aniwatch: [HD1, HD2, StreamSB, StreamTape]
|
||||
# 'top' can also be used as a value for this option
|
||||
# 'top' will cause fastanime to auto select the first server it sees
|
||||
# this saves on resources and is faster since not all servers are being fetched
|
||||
server = {self.server}
|
||||
|
||||
# Auto select next episode [True/False]
|
||||
# this makes fastanime increment the current episode number
|
||||
# then after using that value to fetch the next episode instead of prompting
|
||||
# this option is useful for binging
|
||||
auto_next = {self.auto_next}
|
||||
|
||||
# Auto select the anime provider results with fuzzy find. [True/False]
|
||||
# Note this won't always be correct
|
||||
# this is because the providers sometime use non-standard names
|
||||
# that are there own preference rather than the official names
|
||||
# But 99% of the time will be accurate
|
||||
# if this happens just turn of auto_select in the menus or from the commandline and manually select the correct anime title
|
||||
# and then please open an issue at <> highlighting the normalized title and the title given by the provider for the anime you wished to watch
|
||||
# or even better edit this file <> and open a pull request
|
||||
auto_select = {self.auto_select}
|
||||
|
||||
# whether to skip the opening and ending theme songs [True/False]
|
||||
# NOTE: requires ani-skip to be in path
|
||||
# for python-mpv users am planning to create this functionality n python without the use of an external script
|
||||
# so its disabled for now
|
||||
skip = {self.skip}
|
||||
|
||||
# the maximum delta time in minutes after which the episode should be considered as completed
|
||||
# used in the continue from time stamp
|
||||
error = {self.error}
|
||||
|
||||
# whether to use python-mpv [True/False]
|
||||
# to enable superior control over the player
|
||||
# adding more options to it
|
||||
# Enable this one and you will be wonder why you did not discover fastanime sooner
|
||||
# Since you basically don't have to close the player window to go to the next or previous episode, switch servers, change translation type or change to a given episode x
|
||||
# so try it if you haven't already
|
||||
# if you have any issues setting it up
|
||||
# don't be afraid to ask
|
||||
# especially on windows
|
||||
# honestly it can be a pain to set it up there
|
||||
# personally it took me quite sometime to figure it out
|
||||
# this is because of how windows handles shared libraries
|
||||
# so just ask when you find yourself stuck
|
||||
# or just switch to arch linux
|
||||
use_python_mpv = {self.use_python_mpv}
|
||||
|
||||
# force mpv window
|
||||
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
|
||||
# done for asthetics
|
||||
# passed directly to mpv so values are same
|
||||
force_window = immediate
|
||||
|
||||
# the format of downloaded anime and trailer
|
||||
# based on yt-dlp format and passed directly to it
|
||||
# learn more by looking it up on their site
|
||||
# only works for downloaded anime if:
|
||||
# provider=allanime, server=gogoanime
|
||||
# provider=allanime, server=wixmp
|
||||
# provider=aniwatch
|
||||
# this is because they provider a m3u8 file that contans multiple quality streams
|
||||
format = {self.format}
|
||||
|
||||
# NOTE:
|
||||
# if you have any trouble setting up your config
|
||||
# please don't be afraid to ask in our discord
|
||||
# plus if there are any errors, improvements or suggestions please tell us in the discord
|
||||
# or help us by contributing
|
||||
# we appreciate all the help we can get
|
||||
# since we may not always have the time to immediately implement the changes
|
||||
#
|
||||
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
|
||||
#
|
||||
"""
|
||||
return current_config_state
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,380 +0,0 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from threading import Thread
|
||||
|
||||
import requests
|
||||
from yt_dlp.utils import clean_html, sanitize_filename
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ...libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
from ...Utility import anilist_data_helper
|
||||
from ..utils.scripts import fzf_preview
|
||||
from ..utils.utils import get_true_fg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- aniskip intergration ----
|
||||
def aniskip(mal_id: int, episode: str):
|
||||
"""helper function to be used for setting and getting skip data
|
||||
|
||||
Args:
|
||||
mal_id: mal id of the anime
|
||||
episode: episode number
|
||||
|
||||
Returns:
|
||||
mpv chapter options
|
||||
"""
|
||||
ANISKIP = shutil.which("ani-skip")
|
||||
if not ANISKIP:
|
||||
print("Aniskip not found, please install and try again")
|
||||
return
|
||||
args = [ANISKIP, "-q", str(mal_id), "-e", str(episode)]
|
||||
aniskip_result = subprocess.run(args, text=True, stdout=subprocess.PIPE)
|
||||
if aniskip_result.returncode != 0:
|
||||
return
|
||||
mpv_skip_args = aniskip_result.stdout.strip()
|
||||
return mpv_skip_args.split(" ")
|
||||
|
||||
|
||||
# ---- prevew stuff ----
|
||||
# import tempfile
|
||||
|
||||
# NOTE: May change this to a temp dir but there were issues so later
|
||||
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
|
||||
|
||||
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
|
||||
if not os.path.exists(IMAGES_CACHE_DIR):
|
||||
os.mkdir(IMAGES_CACHE_DIR)
|
||||
ANIME_INFO_CACHE_DIR = os.path.join(WORKING_DIR, "info")
|
||||
if not os.path.exists(ANIME_INFO_CACHE_DIR):
|
||||
os.mkdir(ANIME_INFO_CACHE_DIR)
|
||||
|
||||
|
||||
def save_image_from_url(url: str, file_name: str):
|
||||
"""Helper function that downloads an image to the FastAnime images cache dir given its url and filename
|
||||
|
||||
Args:
|
||||
url: image url to download
|
||||
file_name: filename to use
|
||||
"""
|
||||
image = requests.get(url)
|
||||
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
|
||||
f.write(image.content)
|
||||
|
||||
|
||||
def save_info_from_str(info: str, file_name: str):
|
||||
"""Helper function that writes text (anime details and info) to a file given its filename
|
||||
|
||||
Args:
|
||||
info: the information anilist has on the anime
|
||||
file_name: the filename to use
|
||||
"""
|
||||
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
|
||||
f.write(info)
|
||||
|
||||
|
||||
def write_search_results(
|
||||
anilist_results: list[AnilistBaseMediaDataSchema],
|
||||
titles: list[str],
|
||||
workers: int | None = None,
|
||||
):
|
||||
"""A helper function used by and run in a background thread by get_fzf_preview function inorder to get the actual preview data to be displayed by fzf
|
||||
|
||||
Args:
|
||||
anilist_results: the anilist results from an anilist action
|
||||
titles: sanitized anime titles
|
||||
workers:number of threads to use defaults to as many as possible
|
||||
"""
|
||||
# NOTE: Will probably make this a configuraable option
|
||||
HEADER_COLOR = 215, 0, 95
|
||||
SEPARATOR_COLOR = 208, 208, 208
|
||||
SEPARATOR_WIDTH = 30
|
||||
# use concurency to download and write as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_task = {}
|
||||
for anime, title in zip(anilist_results, titles):
|
||||
# actual image url
|
||||
image_url = anime["coverImage"]["large"]
|
||||
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
|
||||
image_url
|
||||
)
|
||||
|
||||
mediaListName = "Not in any of your lists"
|
||||
progress = "UNKNOWN"
|
||||
if anime_list := anime["mediaListEntry"]:
|
||||
mediaListName = anime_list["status"]
|
||||
progress = anime_list["progress"]
|
||||
# handle the text data
|
||||
template = f"""
|
||||
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
|
||||
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
|
||||
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
|
||||
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
|
||||
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
|
||||
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
|
||||
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
|
||||
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
|
||||
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
|
||||
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
|
||||
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
|
||||
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
|
||||
{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName}
|
||||
{get_true_fg('Progress:',*HEADER_COLOR)} {progress}
|
||||
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
|
||||
{get_true_fg('Description:',*HEADER_COLOR)}
|
||||
"""
|
||||
template = textwrap.dedent(template)
|
||||
template = f"""
|
||||
{template}
|
||||
{textwrap.fill(clean_html(
|
||||
str(anime['description'])), width=45)}
|
||||
"""
|
||||
future_to_task[executor.submit(save_info_from_str, template, title)] = title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_task):
|
||||
task = future_to_task[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
logger.error("%r generated an exception: %s" % (task, exc))
|
||||
|
||||
|
||||
# get rofi icons
|
||||
def get_rofi_icons(
|
||||
anilist_results: list[AnilistBaseMediaDataSchema], titles, workers=None
|
||||
):
|
||||
"""A helper function to make sure that the images are downloaded so they can be used as icons
|
||||
|
||||
Args:
|
||||
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
|
||||
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
|
||||
anilist_results: the anilist results from an anilist action
|
||||
"""
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for anime, title in zip(anilist_results, titles):
|
||||
# actual link to download image from
|
||||
image_url = anime["coverImage"]["large"]
|
||||
future_to_url[executor.submit(save_image_from_url, image_url, title)] = (
|
||||
image_url
|
||||
)
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
|
||||
# get rofi icons
|
||||
def get_fzf_manga_preview(manga_results, workers=None, wait=False):
|
||||
"""A helper function to make sure that the images are downloaded so they can be used as icons
|
||||
|
||||
Args:
|
||||
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
|
||||
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
|
||||
anilist_results: the anilist results from an anilist action
|
||||
"""
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for manga in manga_results:
|
||||
image_url = manga["poster"]
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
save_image_from_url,
|
||||
image_url,
|
||||
sanitize_filename(manga["title"]),
|
||||
)
|
||||
] = image_url
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
background_worker = Thread(
|
||||
target=_worker,
|
||||
)
|
||||
# ensure images and info exists
|
||||
background_worker.daemon = True
|
||||
background_worker.start()
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then fzf-preview %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
|
||||
|
||||
# get rofi icons
|
||||
def get_fzf_episode_preview(
|
||||
anilist_result: AnilistBaseMediaDataSchema, episodes, workers=None, wait=False
|
||||
):
|
||||
"""A helper function to make sure that the images are downloaded so they can be used as icons
|
||||
|
||||
Args:
|
||||
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
|
||||
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
|
||||
anilist_results: the anilist results from an anilist action
|
||||
"""
|
||||
|
||||
HEADER_COLOR = 215, 0, 95
|
||||
import re
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode in episodes:
|
||||
episode_title = ""
|
||||
image_url = ""
|
||||
for episode_detail in anilist_result["streamingEpisodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
image_url = episode_detail["thumbnail"]
|
||||
|
||||
if episode_title and image_url:
|
||||
# actual link to download image from
|
||||
if not image_url:
|
||||
continue
|
||||
future_to_url[
|
||||
executor.submit(save_image_from_url, image_url, episode)
|
||||
] = image_url
|
||||
template = textwrap.dedent(
|
||||
f"""
|
||||
{get_true_fg('Anime Title:',*HEADER_COLOR)} {anilist_result['title']['romaji'] or anilist_result['title']['english']}
|
||||
{get_true_fg('Episode Title:',*HEADER_COLOR)} {episode_title}
|
||||
"""
|
||||
)
|
||||
future_to_url[
|
||||
executor.submit(save_info_from_str, template, episode)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
background_worker = Thread(
|
||||
target=_worker,
|
||||
)
|
||||
# ensure images and info exists
|
||||
background_worker.daemon = True
|
||||
background_worker.start()
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then fzf-preview %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
if [ -s %s/{} ]; then cat %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
|
||||
|
||||
def get_fzf_anime_preview(
|
||||
anilist_results: list[AnilistBaseMediaDataSchema], titles, wait=False
|
||||
):
|
||||
"""A helper function that constructs data to be used for the fzf preview
|
||||
|
||||
Args:
|
||||
titles (list[str]): The sanitized titles to use, NOTE: its important that they are sanitized since thay will be used as filenames
|
||||
wait (bool): whether to block the ui as we wait for preview defaults to false
|
||||
anilist_results: the anilist results got from an anilist action
|
||||
|
||||
Returns:
|
||||
THe fzf preview script to use
|
||||
"""
|
||||
# ensure images and info exists
|
||||
from ...constants import S_PLATFORM
|
||||
background_worker = Thread(
|
||||
target=write_search_results, args=(anilist_results, titles)
|
||||
)
|
||||
background_worker.daemon = True
|
||||
background_worker.start()
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
if S_PLATFORM == "win32":
|
||||
preview = """
|
||||
%s
|
||||
title={}
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ -s "%s\\\\\\$title" ]; then
|
||||
if command -v chafa >/dev/null;then
|
||||
chafa -f kitty -s $dim "%s\\\\\\$title"
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
if [ -s "%s\\\\\\$title" ]; then cat "%s\\\\\\$title"
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
|
||||
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
|
||||
)
|
||||
else:
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then fzf-preview %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
if [ -s %s/{} ]; then cat %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
@@ -1,131 +0,0 @@
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from fastanime.constants import S_PLATFORM
|
||||
|
||||
|
||||
def stream_video(MPV, url, mpv_args, custom_args):
|
||||
process = subprocess.Popen(
|
||||
[MPV, url, *mpv_args, *custom_args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
last_time = None
|
||||
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
|
||||
last_time = "0"
|
||||
total_time = "0"
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not process.stderr:
|
||||
continue
|
||||
output = process.stderr.readline()
|
||||
|
||||
if output:
|
||||
# Match the timestamp in the output
|
||||
match = av_time_pattern.search(output.strip())
|
||||
if match:
|
||||
current_time = match.group(1)
|
||||
total_time = match.group(2)
|
||||
match.group(3)
|
||||
last_time = current_time
|
||||
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
|
||||
|
||||
# Check if the process has terminated
|
||||
retcode = process.poll()
|
||||
if retcode is not None:
|
||||
print("Finshed at: ", last_time)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
process.terminate()
|
||||
|
||||
return last_time, total_time
|
||||
|
||||
|
||||
def run_mpv(
|
||||
link: str,
|
||||
title: str | None = "",
|
||||
start_time: str = "0",
|
||||
ytdl_format="",
|
||||
custom_args=[],
|
||||
headers={},
|
||||
subtitles=[],
|
||||
):
|
||||
# Determine if mpv is available
|
||||
MPV = shutil.which("mpv")
|
||||
|
||||
# If title is None, set a default value
|
||||
|
||||
# Regex to check if the link is a YouTube URL
|
||||
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
|
||||
|
||||
if not MPV and not S_PLATFORM == "win32":
|
||||
# Determine if the link is a YouTube URL
|
||||
if re.match(youtube_regex, link):
|
||||
# Android specific commands to launch mpv with a YouTube URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"com.google.android.youtube/.UrlActivity",
|
||||
]
|
||||
return "0", "0"
|
||||
else:
|
||||
# Android specific commands to launch mpv with a regular URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"is.xyz.mpv/.MPVActivity",
|
||||
]
|
||||
|
||||
subprocess.run(args)
|
||||
return "0", "0"
|
||||
else:
|
||||
# General mpv command with custom arguments
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
for subtitle in subtitles:
|
||||
mpv_args.append(f"--sub-file={subtitle['url']}")
|
||||
if start_time != "0":
|
||||
mpv_args.append(f"--start={start_time}")
|
||||
if title:
|
||||
mpv_args.append(f"--title={title}")
|
||||
if ytdl_format:
|
||||
mpv_args.append(f"--ytdl-format={ytdl_format}")
|
||||
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
|
||||
return stop_time, total_time
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
run_mpv(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"Example Video",
|
||||
"--fullscreen",
|
||||
"--volume=50",
|
||||
)
|
||||
@@ -1,374 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import mpv
|
||||
|
||||
from ...anilist import AniList
|
||||
from .utils import filter_by_quality, move_preferred_subtitle_lang_to_top
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ..config import Config
|
||||
from .tools import FastAnimeRuntimeState
|
||||
|
||||
|
||||
def format_time(duration_in_secs: float):
|
||||
h = duration_in_secs // 3600
|
||||
m = duration_in_secs // 60
|
||||
s = duration_in_secs - ((h * 3600) + (m * 60))
|
||||
return f"{int(h):2d}:{int(m):2d}:{int(s):2d}".replace(" ", "0")
|
||||
|
||||
|
||||
class MpvPlayer(object):
|
||||
anime_provider: "AnimeProvider"
|
||||
config: "Config"
|
||||
subs = []
|
||||
mpv_player: "mpv.MPV"
|
||||
last_stop_time: str = "0"
|
||||
last_total_time: str = "0"
|
||||
last_stop_time_secs = 0
|
||||
last_total_time_secs = 0
|
||||
current_media_title = ""
|
||||
player_fetching = False
|
||||
|
||||
def get_episode(
|
||||
self,
|
||||
type: "Literal['next','previous','reload','custom']",
|
||||
ep_no=None,
|
||||
server="top",
|
||||
):
|
||||
fastanime_runtime_state = self.fastanime_runtime_state
|
||||
config = self.config
|
||||
current_episode_number: str = (
|
||||
fastanime_runtime_state.provider_current_episode_number
|
||||
)
|
||||
quality = config.quality
|
||||
total_episodes: list = sorted(
|
||||
fastanime_runtime_state.provider_available_episodes, key=float
|
||||
)
|
||||
anime_id_anilist: int = fastanime_runtime_state.selected_anime_id_anilist
|
||||
provider_anime = fastanime_runtime_state.provider_anime
|
||||
translation_type = config.translation_type
|
||||
anime_provider = config.anime_provider
|
||||
self.last_stop_time: str = "0"
|
||||
self.last_total_time: str = "0"
|
||||
self.last_stop_time_secs = 0
|
||||
self.last_total_time_secs = 0
|
||||
|
||||
# next or prev
|
||||
if type == "next":
|
||||
self.mpv_player.show_text("Fetching next episode...")
|
||||
next_episode = total_episodes.index(current_episode_number) + 1
|
||||
if next_episode >= len(total_episodes):
|
||||
next_episode = len(total_episodes) - 1
|
||||
fastanime_runtime_state.provider_current_episode_number = total_episodes[
|
||||
next_episode
|
||||
]
|
||||
current_episode_number = (
|
||||
fastanime_runtime_state.provider_current_episode_number
|
||||
)
|
||||
config.update_watch_history(anime_id_anilist, str(current_episode_number))
|
||||
elif type == "reload":
|
||||
if current_episode_number not in total_episodes:
|
||||
self.mpv_player.show_text("Episode not available")
|
||||
return
|
||||
self.mpv_player.show_text("Replaying Episode...")
|
||||
elif type == "custom":
|
||||
if not ep_no or ep_no not in total_episodes:
|
||||
self.mpv_player.show_text("Episode number not specified or invalid")
|
||||
self.mpv_player.show_text(
|
||||
f"Acceptable episodes are: {total_episodes}",
|
||||
)
|
||||
return
|
||||
|
||||
self.mpv_player.show_text(f"Fetching episode {ep_no}")
|
||||
current_episode_number = ep_no
|
||||
config.update_watch_history(anime_id_anilist, str(ep_no))
|
||||
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
|
||||
else:
|
||||
self.mpv_player.show_text("Fetching previous episode...")
|
||||
prev_episode = total_episodes.index(current_episode_number) - 1
|
||||
if prev_episode <= 0:
|
||||
prev_episode = 0
|
||||
fastanime_runtime_state.provider_current_episode_number = total_episodes[
|
||||
prev_episode
|
||||
]
|
||||
current_episode_number = (
|
||||
fastanime_runtime_state.provider_current_episode_number
|
||||
)
|
||||
config.update_watch_history(anime_id_anilist, str(current_episode_number))
|
||||
# update episode progress
|
||||
if config.user and current_episode_number:
|
||||
AniList.update_anime_list(
|
||||
{
|
||||
"mediaId": anime_id_anilist,
|
||||
"progress": int(float(current_episode_number)),
|
||||
}
|
||||
)
|
||||
# get them juicy streams
|
||||
episode_streams = anime_provider.get_episode_streams(
|
||||
provider_anime,
|
||||
current_episode_number,
|
||||
translation_type,
|
||||
)
|
||||
if not episode_streams:
|
||||
self.mpv_player.show_text("No streams were found")
|
||||
return
|
||||
|
||||
# always select the first
|
||||
if server == "top":
|
||||
selected_server = next(episode_streams, None)
|
||||
if not selected_server:
|
||||
self.mpv_player.show_text("Sth went wrong when loading the episode")
|
||||
return
|
||||
else:
|
||||
episode_streams_dict = {
|
||||
episode_stream["server"]: episode_stream
|
||||
for episode_stream in episode_streams
|
||||
}
|
||||
selected_server = episode_streams_dict.get(server)
|
||||
if selected_server is None:
|
||||
self.mpv_player.show_text(
|
||||
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
|
||||
)
|
||||
return
|
||||
self.current_media_title = selected_server["episode_title"]
|
||||
if config.normalize_titles:
|
||||
import re
|
||||
|
||||
for episode_detail in fastanime_runtime_state.selected_anime_anilist[
|
||||
"streamingEpisodes"
|
||||
]:
|
||||
if re.match(
|
||||
f"Episode {current_episode_number} ", episode_detail["title"]
|
||||
):
|
||||
self.current_media_title = episode_detail["title"]
|
||||
break
|
||||
|
||||
links = selected_server["links"]
|
||||
|
||||
stream_link_ = filter_by_quality(quality, links)
|
||||
if not stream_link_:
|
||||
self.mpv_player.show_text("Quality not found")
|
||||
return
|
||||
self.mpv_player._set_property("start", "0")
|
||||
stream_link = stream_link_["link"]
|
||||
fastanime_runtime_state.provider_current_episode_stream_link = stream_link
|
||||
self.subs = move_preferred_subtitle_lang_to_top(
|
||||
selected_server["subtitles"], config.sub_lang
|
||||
)
|
||||
return stream_link
|
||||
|
||||
def create_player(
|
||||
self,
|
||||
stream_link,
|
||||
anime_provider: "AnimeProvider",
|
||||
fastanime_runtime_state: "FastAnimeRuntimeState",
|
||||
config: "Config",
|
||||
title,
|
||||
start_time,
|
||||
headers={},
|
||||
subtitles=[],
|
||||
):
|
||||
self.subs = subtitles
|
||||
self.anime_provider = anime_provider
|
||||
self.fastanime_runtime_state = fastanime_runtime_state
|
||||
self.config = config
|
||||
self.last_stop_time: str = "0"
|
||||
self.last_total_time: str = "0"
|
||||
self.last_stop_time_secs = 0
|
||||
self.last_total_time_secs = 0
|
||||
self.current_media_title = ""
|
||||
|
||||
mpv_player = mpv.MPV(
|
||||
log_handler=print,
|
||||
loglevel="error",
|
||||
config=True,
|
||||
input_default_bindings=True,
|
||||
input_vo_keyboard=True,
|
||||
osc=True,
|
||||
ytdl=True,
|
||||
)
|
||||
|
||||
# -- events --
|
||||
@mpv_player.event_callback("file-loaded")
|
||||
def set_total_time(event, *args):
|
||||
d = mpv_player._get_property("duration")
|
||||
self.player_fetching = False
|
||||
if isinstance(d, float):
|
||||
self.last_total_time = format_time(d)
|
||||
try:
|
||||
if not mpv_player.core_shutdown:
|
||||
if self.subs:
|
||||
for i, subtitle in enumerate(self.subs):
|
||||
if i == 0:
|
||||
flag = "select"
|
||||
else:
|
||||
flag = "auto"
|
||||
mpv_player.sub_add(
|
||||
subtitle["url"], flag, None, subtitle["language"]
|
||||
)
|
||||
self.subs = []
|
||||
except mpv.ShutdownError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@mpv_player.property_observer("time-pos")
|
||||
def handle_time_start_update(*args):
|
||||
if len(args) > 1:
|
||||
value = args[1]
|
||||
if value is not None:
|
||||
self.last_stop_time = format_time(value)
|
||||
|
||||
@mpv_player.property_observer("time-remaining")
|
||||
def handle_time_remaining_update(
|
||||
property, time_remaining: float | None = None, *args
|
||||
):
|
||||
if time_remaining is not None:
|
||||
if time_remaining < 1 and config.auto_next and not self.player_fetching:
|
||||
print("Auto Fetching Next Episode")
|
||||
self.player_fetching = True
|
||||
url = self.get_episode("next")
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
# -- keybindings --
|
||||
@mpv_player.on_key_press("shift+n")
|
||||
def _next_episode():
|
||||
url = self.get_episode("next")
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
@mpv_player.on_key_press("shift+p")
|
||||
def _previous_episode():
|
||||
url = self.get_episode("previous")
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
@mpv_player.on_key_press("shift+a")
|
||||
def _toggle_auto_next():
|
||||
config.auto_next = not config.auto_next
|
||||
if config.auto_next:
|
||||
mpv_player.show_text("Auto next enabled")
|
||||
else:
|
||||
mpv_player.show_text("Auto next disabled")
|
||||
|
||||
@mpv_player.on_key_press("shift+t")
|
||||
def _toggle_translation_type():
|
||||
translation_type = "sub" if config.translation_type == "dub" else "dub"
|
||||
mpv_player.show_text("Changing translation type...")
|
||||
anime = anime_provider.get_anime(
|
||||
fastanime_runtime_state.provider_anime_search_result["id"],
|
||||
)
|
||||
if not anime:
|
||||
mpv_player.show_text("Failed to update translation type")
|
||||
return
|
||||
fastanime_runtime_state.provider_available_episodes = anime[
|
||||
"availableEpisodesDetail"
|
||||
][translation_type]
|
||||
config.translation_type = translation_type
|
||||
|
||||
if config.translation_type == "dub":
|
||||
mpv_player.show_text("Translation Type set to dub")
|
||||
else:
|
||||
mpv_player.show_text("Translation Type set to sub")
|
||||
|
||||
@mpv_player.on_key_press("shift+r")
|
||||
def _reload():
|
||||
url = self.get_episode("reload")
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
# -- script messages --
|
||||
@mpv_player.message_handler("select-episode")
|
||||
def select_episode(episode: bytes | None = None, *args):
|
||||
if not episode:
|
||||
mpv_player.show_text("No episode was selected")
|
||||
return
|
||||
url = self.get_episode("custom", episode.decode())
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
@mpv_player.message_handler("select-server")
|
||||
def select_server(server: bytes | None = None, *args):
|
||||
if not server:
|
||||
mpv_player.show_text("No server was selected")
|
||||
return
|
||||
url = self.get_episode("reload", server=server.decode())
|
||||
if url:
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
else:
|
||||
pass
|
||||
|
||||
@mpv_player.message_handler("select-quality")
|
||||
def select_quality(quality_raw: bytes | None = None, *args):
|
||||
if not quality_raw:
|
||||
mpv_player.show_text("No quality was selected")
|
||||
return
|
||||
q = ["360", "720", "1080"]
|
||||
quality = quality_raw.decode()
|
||||
links: list = fastanime_runtime_state.provider_server_episode_streams
|
||||
q = [link["quality"] for link in links]
|
||||
if quality in q:
|
||||
config.quality = quality
|
||||
stream_link_ = filter_by_quality(quality, links)
|
||||
if not stream_link_:
|
||||
mpv_player.show_text("Quality not found")
|
||||
return
|
||||
mpv_player.show_text(f"Changing to stream of quality {quality}")
|
||||
stream_link = stream_link_["link"]
|
||||
mpv_player.loadfile(stream_link)
|
||||
else:
|
||||
mpv_player.show_text(f"invalid quality!! Valid quality includes: {q}")
|
||||
|
||||
# -- events --
|
||||
mpv_player.observe_property("time-pos", handle_time_start_update)
|
||||
mpv_player.observe_property("time-remaining", handle_time_remaining_update)
|
||||
mpv_player.register_event_callback(set_total_time)
|
||||
|
||||
# --script-messages --
|
||||
mpv_player.register_message_handler("select-episode", select_episode)
|
||||
mpv_player.register_message_handler("select-server", select_server)
|
||||
mpv_player.register_message_handler("select-quality", select_quality)
|
||||
|
||||
self.mpv_player = mpv_player
|
||||
mpv_player.force_window = config.force_window
|
||||
# mpv_player.cache = "yes"
|
||||
# mpv_player.cache_pause = "no"
|
||||
mpv_player.title = title
|
||||
mpv_headers = ""
|
||||
if headers:
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_player.http_header_fields = mpv_headers
|
||||
|
||||
mpv_player.play(stream_link)
|
||||
|
||||
if not start_time == "0":
|
||||
mpv_player.start = start_time
|
||||
|
||||
mpv_player.wait_for_shutdown()
|
||||
mpv_player.terminate()
|
||||
|
||||
|
||||
player = MpvPlayer()
|
||||
@@ -1,27 +0,0 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def print_img(url: str):
|
||||
"""helper funtion to print an image given its url
|
||||
|
||||
Args:
|
||||
url: [TODO:description]
|
||||
"""
|
||||
if EXECUTABLE := shutil.which("icat"):
|
||||
subprocess.run([EXECUTABLE, url])
|
||||
else:
|
||||
EXECUTABLE = shutil.which("chafa")
|
||||
|
||||
if EXECUTABLE is None:
|
||||
print("chafanot found")
|
||||
return
|
||||
|
||||
res = requests.get(url)
|
||||
if res.status_code != 200:
|
||||
print("Error fetching image")
|
||||
return
|
||||
img_bytes = res.content
|
||||
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)
|
||||
@@ -1,78 +0,0 @@
|
||||
# this script was written by the fzf devs as an example on how to preview images
|
||||
# its only here for convinience
|
||||
fzf_preview = r"""
|
||||
#
|
||||
# The purpose of this script is to demonstrate how to preview a file or an
|
||||
# image in the preview window of fzf.
|
||||
#
|
||||
# Dependencies:
|
||||
# - https://github.com/sharkdp/bat
|
||||
# - https://github.com/hpjansson/chafa
|
||||
# - https://iterm2.com/utilities/imgcat
|
||||
fzf-preview() {
|
||||
if [[ $# -ne 1 ]]; then
|
||||
>&2 echo "usage: $0 FILENAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file=${1/#\~\//$HOME/}
|
||||
type=$(file --dereference --mime -- "$file")
|
||||
|
||||
if [[ ! $type =~ image/ ]]; then
|
||||
if [[ $type =~ =binary ]]; then
|
||||
file "$1"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Sometimes bat is installed as batcat.
|
||||
if command -v batcat >/dev/null; then
|
||||
batname="batcat"
|
||||
elif command -v bat >/dev/null; then
|
||||
batname="bat"
|
||||
else
|
||||
cat "$1"
|
||||
exit
|
||||
fi
|
||||
|
||||
${batname} --style="${BAT_STYLE:-numbers}" --color=always --pager=never -- "$file"
|
||||
exit
|
||||
fi
|
||||
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [[ $dim = x ]]; then
|
||||
dim=$(stty size </dev/tty | awk '{print $2 "x" $1}')
|
||||
elif ! [[ $KITTY_WINDOW_ID ]] && ((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size </dev/tty | awk '{print $1}'))); then
|
||||
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
|
||||
# * https://github.com/junegunn/fzf/issues/2544
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
# 1. Use kitty icat on kitty terminal
|
||||
if [[ $KITTY_WINDOW_ID ]]; then
|
||||
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
|
||||
# you have to use 'stream'.
|
||||
#
|
||||
# 2. The last line of the output is the ANSI reset code without newline.
|
||||
# This confuses fzf and makes it render scroll offset indicator.
|
||||
# So we remove the last line and append the reset code to its previous line.
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
|
||||
|
||||
# 2. Use chafa with Sixel output
|
||||
elif command -v chafa >/dev/null; then
|
||||
chafa -f sixel -s "$dim" "$file"
|
||||
# Add a new line character so that fzf can display multiple images in the preview window
|
||||
echo
|
||||
|
||||
# 3. If chafa is not found but imgcat is available, use it on iTerm2
|
||||
elif command -v imgcat >/dev/null; then
|
||||
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
|
||||
# user is running iTerm2. But for the sake of simplicity, we just assume
|
||||
# that's the case here.
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
# 4. Cannot find any suitable method to preview the image
|
||||
else
|
||||
file "$file"
|
||||
fi
|
||||
}
|
||||
"""
|
||||
@@ -1,50 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from ...libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
from ...libs.anime_provider.types import Anime, EpisodeStream, SearchResult, Server
|
||||
|
||||
|
||||
class FastAnimeRuntimeState(object):
|
||||
"""A class that manages fastanime runtime during anilist command runtime"""
|
||||
|
||||
provider_current_episode_stream_link: str
|
||||
provider_current_server: "Server"
|
||||
provider_current_server_name: str
|
||||
provider_available_episodes: list[str]
|
||||
provider_current_episode_number: str
|
||||
provider_server_episode_streams: list["EpisodeStream"]
|
||||
provider_anime_title: str
|
||||
provider_anime: "Anime"
|
||||
provider_anime_search_result: "SearchResult"
|
||||
|
||||
selected_anime_anilist: "AnilistBaseMediaDataSchema"
|
||||
selected_anime_id_anilist: int
|
||||
selected_anime_title_anilist: str
|
||||
# current_anilist_data: "AnilistDataSchema | AnilistMediaList"
|
||||
anilist_results_data: "Any"
|
||||
|
||||
|
||||
def exit_app(exit_code=0, *args):
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from ...constants import APP_NAME, ICON_PATH, USER_NAME
|
||||
|
||||
console = Console()
|
||||
if not console.is_terminal:
|
||||
from plyer import notification
|
||||
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
message=f"Have a good day {USER_NAME}",
|
||||
title="Shutting down",
|
||||
) # pyright:ignore
|
||||
else:
|
||||
console.clear()
|
||||
console.print("Have a good day :smile:", USER_NAME)
|
||||
sys.exit(exit_code)
|
||||
@@ -1,157 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from InquirerPy import inquirer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ...libs.anime_provider.types import EpisodeStream
|
||||
|
||||
# Define ANSI escape codes as constants
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
INVISIBLE_CURSOR = "\033[?25l"
|
||||
VISIBLE_CURSOR = "\033[?25h"
|
||||
UNDERLINE = "\033[4m"
|
||||
|
||||
# ESC[38;2;{r};{g};{b}m
|
||||
BG_GREEN = "\033[48;2;120;233;12;m"
|
||||
GREEN = "\033[38;2;45;24;45;m"
|
||||
|
||||
|
||||
def get_requested_quality_or_default_to_first(url, quality):
|
||||
import yt_dlp
|
||||
|
||||
with yt_dlp.YoutubeDL({"quiet": True, "silent": True, "no_warnings": True}) as ydl:
|
||||
m3u8_info = ydl.extract_info(url, False)
|
||||
if not m3u8_info:
|
||||
return
|
||||
|
||||
m3u8_formats = m3u8_info["formats"]
|
||||
quality = int(quality)
|
||||
quality_u = quality - 80
|
||||
quality_l = quality + 80
|
||||
for m3u8_format in m3u8_formats:
|
||||
if m3u8_format["height"] == quality or (
|
||||
m3u8_format["height"] < quality_u and m3u8_format["height"] > quality_l
|
||||
):
|
||||
return m3u8_format["url"]
|
||||
else:
|
||||
return m3u8_formats[0]["url"]
|
||||
|
||||
|
||||
def move_preferred_subtitle_lang_to_top(sub_list, lang_str):
|
||||
"""Moves the dictionary with the given ID to the front of the list.
|
||||
|
||||
Args:
|
||||
sub_list: list of subs
|
||||
lang_str: the sub lang pref
|
||||
|
||||
Returns:
|
||||
The modified list.
|
||||
"""
|
||||
import re
|
||||
|
||||
for i, d in enumerate(sub_list):
|
||||
if re.search(lang_str, d["language"], re.IGNORECASE):
|
||||
sub_list.insert(0, sub_list.pop(i))
|
||||
break
|
||||
return sub_list
|
||||
|
||||
|
||||
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default=True):
|
||||
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
|
||||
|
||||
Args:
|
||||
quality: the quality to use
|
||||
stream_links: a list of EpisodeStream objects
|
||||
|
||||
Returns:
|
||||
an EpisodeStream object or None incase the quality was not found
|
||||
"""
|
||||
for stream_link in stream_links:
|
||||
q = float(quality)
|
||||
Q = float(stream_link["quality"])
|
||||
# some providers have inaccurate/weird/non-standard eg qualities 718 instead of 720
|
||||
if Q <= q + 80 and Q >= q - 80:
|
||||
return stream_link
|
||||
else:
|
||||
if stream_links and default:
|
||||
from rich import print
|
||||
|
||||
try:
|
||||
print("[yellow bold]WARNING Qualities were:[/] ", stream_links)
|
||||
print(
|
||||
"[cyan bold]Using default of quality:[/] ",
|
||||
stream_links[0]["quality"],
|
||||
)
|
||||
return stream_links[0]
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
|
||||
|
||||
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
|
||||
"""Helper function usedd to format bytes to human
|
||||
|
||||
Args:
|
||||
num_of_bytes: the number of bytes to format
|
||||
suffix: the suffix to use
|
||||
|
||||
Returns:
|
||||
formated bytes
|
||||
"""
|
||||
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
|
||||
if abs(num_of_bytes) < 1024.0:
|
||||
return f"{num_of_bytes:3.1f}{unit}{suffix}"
|
||||
num_of_bytes /= 1024.0
|
||||
return f"{num_of_bytes:.1f}Yi{suffix}"
|
||||
|
||||
|
||||
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
|
||||
"""Custom helper function that enables colored text in the terminal
|
||||
|
||||
Args:
|
||||
bold: whether to bolden the text
|
||||
string: string to color
|
||||
r: red
|
||||
g: green
|
||||
b: blue
|
||||
|
||||
Returns:
|
||||
colored string
|
||||
"""
|
||||
# NOTE: Currently only supports terminals that support true color
|
||||
if bold:
|
||||
return f"{BOLD}\033[38;2;{r};{g};{b};m{string}{RESET}"
|
||||
else:
|
||||
return f"\033[38;2;{r};{g};{b};m{string}{RESET}"
|
||||
|
||||
|
||||
def get_true_bg(string, r: int, g: int, b: int) -> str:
|
||||
return f"\033[48;2;{r};{g};{b};m{string}{RESET}"
|
||||
|
||||
|
||||
def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
|
||||
"""helper function that enables easier interaction with InquirerPy lib
|
||||
|
||||
Args:
|
||||
choices: the choices to prompt
|
||||
prompt: the prompt string to use
|
||||
**kwargs: other options to pass to fuzzy_inquirer
|
||||
|
||||
Returns:
|
||||
a choice
|
||||
"""
|
||||
from click import clear
|
||||
|
||||
clear()
|
||||
action = inquirer.fuzzy( # pyright:ignore
|
||||
prompt,
|
||||
choices,
|
||||
height="100%",
|
||||
border=True,
|
||||
validate=lambda result: result in choices,
|
||||
**kwargs,
|
||||
).execute()
|
||||
return action
|
||||
@@ -1,85 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
|
||||
import click
|
||||
|
||||
from . import APP_NAME, __version__
|
||||
|
||||
PLATFORM = system()
|
||||
|
||||
# ---- app deps ----
|
||||
APP_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
ASSETS_DIR = os.path.join(APP_DIR, "assets")
|
||||
|
||||
|
||||
# --- icon stuff ---
|
||||
if PLATFORM == "Windows":
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
|
||||
else:
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
|
||||
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
|
||||
|
||||
|
||||
# ----- user configs and data -----
|
||||
|
||||
S_PLATFORM = sys.platform
|
||||
APP_DATA_DIR = click.get_app_dir(APP_NAME,roaming=False)
|
||||
if S_PLATFORM == "win32":
|
||||
# app data
|
||||
# app_data_dir_base = os.getenv("LOCALAPPDATA")
|
||||
# if not app_data_dir_base:
|
||||
# raise RuntimeError("Could not determine app data dir please report to devs")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.join(Path().home(), "Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
elif S_PLATFORM == "darwin":
|
||||
# app data
|
||||
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.path.expanduser("~/Library/Caches")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.expanduser("~/Movies")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
else:
|
||||
# # app data
|
||||
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
|
||||
# if not app_data_dir_base.strip():
|
||||
# app_data_dir_base = os.path.expanduser("~/.config")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
|
||||
if not cache_dir_base.strip():
|
||||
cache_dir_base = os.path.expanduser("~/.cache")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
|
||||
if not video_dir_base.strip():
|
||||
video_dir_base = os.path.expanduser("~/Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
# ensure paths exist
|
||||
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# useful paths
|
||||
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
|
||||
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
|
||||
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
|
||||
|
||||
|
||||
USER_NAME = os.environ.get("USERNAME", "Anime fun")
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
his module contains an abstraction for interaction with the anilist api making it easy and efficient
|
||||
"""
|
||||
@@ -1,423 +0,0 @@
|
||||
"""
|
||||
This is the core module availing all the abstractions of the anilist api
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
from .queries_graphql import (
|
||||
airing_schedule_query,
|
||||
anime_characters_query,
|
||||
anime_query,
|
||||
anime_relations_query,
|
||||
delete_list_entry_query,
|
||||
get_logged_in_user_query,
|
||||
get_medialist_item_query,
|
||||
get_user_info,
|
||||
media_list_mutation,
|
||||
media_list_query,
|
||||
most_favourite_query,
|
||||
most_popular_query,
|
||||
most_recently_updated_query,
|
||||
most_scored_query,
|
||||
notification_query,
|
||||
recommended_query,
|
||||
search_query,
|
||||
trending_query,
|
||||
upcoming_anime_query,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .types import (
|
||||
AnilistDataSchema,
|
||||
AnilistMediaLists,
|
||||
AnilistMediaListStatus,
|
||||
AnilistNotifications,
|
||||
AnilistUser_,
|
||||
AnilistUserData,
|
||||
AnilistViewerData,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
|
||||
|
||||
class AniListApi:
|
||||
"""An abstraction over the anilist api offering an easy and simple interface
|
||||
|
||||
Attributes:
|
||||
session: [TODO:attribute]
|
||||
session: [TODO:attribute]
|
||||
token: [TODO:attribute]
|
||||
headers: [TODO:attribute]
|
||||
user_id: [TODO:attribute]
|
||||
token: [TODO:attribute]
|
||||
headers: [TODO:attribute]
|
||||
user_id: [TODO:attribute]
|
||||
"""
|
||||
|
||||
session: requests.Session
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session = requests.session()
|
||||
|
||||
def login_user(self, token: str):
|
||||
"""methosd used to login a new user enabling authenticated requests
|
||||
|
||||
Args:
|
||||
token: anilist app token
|
||||
|
||||
Returns:
|
||||
the logged in user
|
||||
"""
|
||||
self.token = token
|
||||
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||
self.session.headers.update(self.headers)
|
||||
success, user = self.get_logged_in_user()
|
||||
if not user:
|
||||
return
|
||||
if not success or not user:
|
||||
return
|
||||
user_info: "AnilistUser_" = user["data"]["Viewer"]
|
||||
self.user_id = user_info["id"]
|
||||
return user_info
|
||||
|
||||
def get_notification(
|
||||
self,
|
||||
) -> tuple[bool, "AnilistNotifications"] | tuple[bool, None]:
|
||||
"""get the top five latest notifications for anime thats airing
|
||||
|
||||
Returns:
|
||||
airing notifications
|
||||
"""
|
||||
return self._make_authenticated_request(notification_query)
|
||||
|
||||
def update_login_info(self, user: "AnilistUser_", token: str):
|
||||
"""method used to login a user enabling authenticated requests
|
||||
|
||||
Args:
|
||||
user: an anilist user object
|
||||
token: the login token
|
||||
"""
|
||||
self.token = token
|
||||
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||
self.session.headers.update(self.headers)
|
||||
self.user_id = user["id"]
|
||||
|
||||
def get_user_info(self) -> tuple[bool, "AnilistUserData"] | tuple[bool, None]:
|
||||
"""get the details of the user who is currently logged in
|
||||
|
||||
Returns:
|
||||
an anilist user
|
||||
"""
|
||||
|
||||
return self._make_authenticated_request(get_user_info, {"userId": self.user_id})
|
||||
|
||||
def get_logged_in_user(
|
||||
self,
|
||||
) -> tuple[bool, "AnilistViewerData"] | tuple[bool, None]:
|
||||
"""get the details of the user who is currently logged in
|
||||
|
||||
Returns:
|
||||
an anilist user
|
||||
"""
|
||||
if not self.headers:
|
||||
return (False, None)
|
||||
return self._make_authenticated_request(get_logged_in_user_query)
|
||||
|
||||
def update_anime_list(self, values_to_update: dict):
|
||||
"""a powerful method for managing mediaLists giving full power to the user
|
||||
|
||||
Args:
|
||||
values_to_update: a dict containing valid media list options
|
||||
|
||||
Returns:
|
||||
an anilist object indicating success
|
||||
"""
|
||||
variables = {"userId": self.user_id, **values_to_update}
|
||||
return self._make_authenticated_request(media_list_mutation, variables)
|
||||
|
||||
def get_anime_list(
|
||||
self,
|
||||
status: "AnilistMediaListStatus",
|
||||
type="ANIME",
|
||||
) -> tuple[bool, "AnilistMediaLists"] | tuple[bool, None]:
|
||||
"""gets an anime list from your media list given the list status
|
||||
|
||||
Args:
|
||||
status: the mediaListStatus of the anime list
|
||||
|
||||
Returns:
|
||||
a media list
|
||||
"""
|
||||
variables = {"status": status, "userId": self.user_id, "type": type}
|
||||
return self._make_authenticated_request(media_list_query, variables)
|
||||
|
||||
def get_medialist_entry(
|
||||
self, mediaId: int
|
||||
) -> tuple[bool, dict] | tuple[bool, None]:
|
||||
"""Get the id entry of the items in an Anilist MediaList
|
||||
|
||||
Args:
|
||||
mediaId: The mediaList item entry mediaId
|
||||
|
||||
Returns:
|
||||
a boolean indicating whether the request succeeded and either a dict object containing the id of the media list entry
|
||||
"""
|
||||
variables = {"mediaId": mediaId}
|
||||
return self._make_authenticated_request(get_medialist_item_query, variables)
|
||||
|
||||
def delete_medialist_entry(self, mediaId: int):
|
||||
"""Deletes a mediaList item given its mediaId
|
||||
|
||||
Args:
|
||||
mediaId: the media id of the anime
|
||||
|
||||
Returns:
|
||||
a tuple containing a boolean whether the operation was successful and either an anilist object or none depending on success
|
||||
"""
|
||||
result = self.get_medialist_entry(mediaId)
|
||||
data = result[1]
|
||||
if not result[0] or not data:
|
||||
return result
|
||||
id = data["data"]["MediaList"]["id"]
|
||||
variables = {"id": id}
|
||||
return self._make_authenticated_request(delete_list_entry_query, variables)
|
||||
|
||||
# TODO: unify the _make_authenticated_request with original since sessions are now in use
|
||||
def _make_authenticated_request(self, query: str, variables: dict = {}):
|
||||
"""the abstraction over all authenticated requests
|
||||
|
||||
Args:
|
||||
query: the anilist query to make
|
||||
variables: the anilist variables to use
|
||||
|
||||
Returns:
|
||||
an anilist object containing the queried data or none and a boolean indicating whether the request was successful
|
||||
"""
|
||||
try:
|
||||
response = self.session.post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
headers=self.headers,
|
||||
)
|
||||
anilist_data = response.json()
|
||||
|
||||
# ensuring you dont get blocked
|
||||
if (
|
||||
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
|
||||
and not response.status_code == 500
|
||||
):
|
||||
print(
|
||||
"Warning you are exceeding the allowed number of calls per minute"
|
||||
)
|
||||
logger.warning(
|
||||
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
|
||||
)
|
||||
print("Forced timeout will now be initiated")
|
||||
import time
|
||||
|
||||
print("sleeping...")
|
||||
time.sleep(1 * 60)
|
||||
if response.status_code == 200:
|
||||
return (True, anilist_data)
|
||||
else:
|
||||
return (False, anilist_data)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
return (False, None)
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(
|
||||
"ConnectionError this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
return (False, None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
return (False, None) # type: ignore
|
||||
|
||||
def get_data(
|
||||
self, query: str, variables: dict = {}
|
||||
) -> tuple[bool, "AnilistDataSchema"]:
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
try:
|
||||
response = self.session.post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data: AnilistDataSchema = response.json()
|
||||
|
||||
# ensuring you dont get blocked
|
||||
if (
|
||||
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
|
||||
and not response.status_code == 500
|
||||
):
|
||||
print(
|
||||
"Warning you are exceeding the allowed number of calls per minute"
|
||||
)
|
||||
logger.warning(
|
||||
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
|
||||
)
|
||||
print("Forced timeout will now be initiated")
|
||||
import time
|
||||
|
||||
print("sleeping...")
|
||||
time.sleep(1 * 60)
|
||||
if response.status_code == 200:
|
||||
return (True, anilist_data)
|
||||
else:
|
||||
return (False, anilist_data)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
{
|
||||
"Error": "Timeout Exceeded for connection there might be a problem with your internet or anilist is down."
|
||||
},
|
||||
) # type: ignore
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(
|
||||
"ConnectionError this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
{
|
||||
"Error": "There might be a problem with your internet or anilist is down."
|
||||
},
|
||||
) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
return (False, {"Error": f"{e}"}) # type: ignore
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str | None = None,
|
||||
sort: str | None = None,
|
||||
genre_in: list[str] | None = None,
|
||||
id_in: list[int] | None = None,
|
||||
genre_not_in: list[str] = ["hentai"],
|
||||
popularity_greater: int | None = None,
|
||||
popularity_lesser: int | None = None,
|
||||
averageScore_greater: int | None = None,
|
||||
averageScore_lesser: int | None = None,
|
||||
tag_in: list[str] | None = None,
|
||||
tag_not_in: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
status_in: list[str] | None = None,
|
||||
status_not_in: list[str] | None = None,
|
||||
endDate_greater: int | None = None,
|
||||
endDate_lesser: int | None = None,
|
||||
startDate_greater: int | None = None,
|
||||
startDate_lesser: int | None = None,
|
||||
startDate: str | None = None,
|
||||
seasonYear: str | None = None,
|
||||
page: int | None = None,
|
||||
season: str | None = None,
|
||||
format_in: list[str] | None = None,
|
||||
on_list: bool | None = None,
|
||||
type="ANIME",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
A powerful method abstracting all of anilist media queries
|
||||
"""
|
||||
variables = {}
|
||||
for key, val in list(locals().items())[1:]:
|
||||
if (val or val is False) and key not in ["variables"]:
|
||||
variables[key] = val
|
||||
search_results = self.get_data(search_query, variables=variables)
|
||||
return search_results
|
||||
|
||||
def get_anime(self, id: int):
|
||||
"""
|
||||
Gets a single anime by a valid anilist anime id
|
||||
"""
|
||||
variables = {"id": id}
|
||||
return self.get_data(anime_query, variables)
|
||||
|
||||
def get_trending(self, type="ANIME", *_, **kwargs):
|
||||
"""
|
||||
Gets the currently trending anime
|
||||
"""
|
||||
variables = {"type": type}
|
||||
trending = self.get_data(trending_query, variables)
|
||||
return trending
|
||||
|
||||
def get_most_favourite(self, type="ANIME", *_, **kwargs):
|
||||
"""
|
||||
Gets the most favoured anime on anilist
|
||||
"""
|
||||
variables = {"type": type}
|
||||
most_favourite = self.get_data(most_favourite_query, variables)
|
||||
return most_favourite
|
||||
|
||||
def get_most_scored(self, type="ANIME", *_, **kwargs):
|
||||
"""
|
||||
Gets most scored anime on anilist
|
||||
"""
|
||||
variables = {"type": type}
|
||||
most_scored = self.get_data(most_scored_query, variables)
|
||||
return most_scored
|
||||
|
||||
def get_most_recently_updated(self, type="ANIME", *_, **kwargs):
|
||||
"""
|
||||
Gets most recently updated anime from anilist
|
||||
"""
|
||||
variables = {"type": type}
|
||||
most_recently_updated = self.get_data(most_recently_updated_query, variables)
|
||||
return most_recently_updated
|
||||
|
||||
def get_most_popular(
|
||||
self,
|
||||
type="ANIME",
|
||||
):
|
||||
"""
|
||||
Gets most popular anime on anilist
|
||||
"""
|
||||
variables = {"type": type}
|
||||
most_popular = self.get_data(most_popular_query, variables)
|
||||
return most_popular
|
||||
|
||||
def get_upcoming_anime(self, type="ANIME", page: int = 1, *_, **kwargs):
|
||||
"""
|
||||
Gets upcoming anime from anilist
|
||||
"""
|
||||
variables = {"page": page, "type": type}
|
||||
upcoming_anime = self.get_data(upcoming_anime_query, variables)
|
||||
return upcoming_anime
|
||||
|
||||
# NOTE: THe following methods will probably be scraped soon
|
||||
def get_recommended_anime_for(self, id: int, type="ANIME", *_, **kwargs):
|
||||
variables = {"type": type}
|
||||
recommended_anime = self.get_data(recommended_query, variables)
|
||||
return recommended_anime
|
||||
|
||||
def get_charcters_of(self, id: int, type="ANIME", *_, **kwargs):
|
||||
variables = {"id": id}
|
||||
characters = self.get_data(anime_characters_query, variables)
|
||||
return characters
|
||||
|
||||
def get_related_anime_for(self, id: int, type="ANIME", *_, **kwargs):
|
||||
variables = {"id": id}
|
||||
related_anime = self.get_data(anime_relations_query, variables)
|
||||
return related_anime
|
||||
|
||||
def get_airing_schedule_for(self, id: int, type="ANIME", *_, **kwargs):
|
||||
variables = {"id": id}
|
||||
airing_schedule = self.get_data(airing_schedule_query, variables)
|
||||
return airing_schedule
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS
|
||||
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHESERVERS
|
||||
from .aniwatch.constants import SERVERS_AVAILABLE as ANIWATCHSERVERS
|
||||
|
||||
anime_sources = {
|
||||
"allanime": "api.AllAnimeAPI",
|
||||
"animepahe": "api.AnimePaheApi",
|
||||
"aniwatch": "api.AniWatchApi",
|
||||
"aniwave": "api.AniWaveApi",
|
||||
}
|
||||
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHESERVERS, *ANIWATCHSERVERS]
|
||||
@@ -1,315 +0,0 @@
|
||||
"""a module that handles the scraping of allanime
|
||||
|
||||
abstraction over allanime api
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
from ...anime_provider.base_provider import AnimeProvider
|
||||
from ..utils import give_random_quality, one_digit_symmetric_xor
|
||||
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
|
||||
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .types import AllAnimeEpisode
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: create tests for the api
|
||||
#
|
||||
# ** Based on ani-cli **
|
||||
class AllAnimeAPI(AnimeProvider):
|
||||
"""
|
||||
Provides a fast and effective interface to AllAnime site.
|
||||
"""
|
||||
|
||||
api_endpoint = ALLANIME_API_ENDPOINT
|
||||
HEADERS = {
|
||||
"Referer": ALLANIME_REFERER,
|
||||
}
|
||||
|
||||
def _fetch_gql(self, query: str, variables: dict):
|
||||
"""main abstraction over all requests to the allanime api
|
||||
|
||||
Args:
|
||||
query: [TODO:description]
|
||||
variables: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
try:
|
||||
response = self.session.get(
|
||||
self.api_endpoint,
|
||||
params={
|
||||
"variables": json.dumps(variables),
|
||||
"query": query,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.ok:
|
||||
return response.json()["data"]
|
||||
else:
|
||||
logger.error("[ALLANIME-ERROR]: ", response.text)
|
||||
return {}
|
||||
except Timeout:
|
||||
logger.error(
|
||||
"[ALLANIME-ERROR]: Timeout exceeded this could mean allanime is down or you have lost internet connection"
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def search_for_anime(
|
||||
self,
|
||||
user_query: str,
|
||||
translation_type: str = "sub",
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
**kwargs,
|
||||
):
|
||||
"""search for an anime title using allanime provider
|
||||
|
||||
Args:
|
||||
nsfw ([TODO:parameter]): [TODO:description]
|
||||
unknown ([TODO:parameter]): [TODO:description]
|
||||
user_query: [TODO:description]
|
||||
translation_type: [TODO:description]
|
||||
**kwargs: [TODO:args]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
search = {"allowAdult": nsfw, "allowUnknown": unknown, "query": user_query}
|
||||
limit = 40
|
||||
translationtype = translation_type
|
||||
countryorigin = "all"
|
||||
page = 1
|
||||
variables = {
|
||||
"search": search,
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"translationtype": translationtype,
|
||||
"countryorigin": countryorigin,
|
||||
}
|
||||
try:
|
||||
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
|
||||
page_info = search_results["shows"]["pageInfo"]
|
||||
results = []
|
||||
for result in search_results["shows"]["edges"]:
|
||||
normalized_result = {
|
||||
"id": result["_id"],
|
||||
"title": result["name"],
|
||||
"type": result["__typename"],
|
||||
"availableEpisodes": result["availableEpisodes"],
|
||||
}
|
||||
results.append(normalized_result)
|
||||
|
||||
normalized_search_results = {
|
||||
"pageInfo": page_info,
|
||||
"results": results,
|
||||
}
|
||||
return normalized_search_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def get_anime(self, allanime_show_id: str):
|
||||
"""get an anime details given its id
|
||||
|
||||
Args:
|
||||
allanime_show_id: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
variables = {"showId": allanime_show_id}
|
||||
try:
|
||||
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
|
||||
id: str = anime["show"]["_id"]
|
||||
title: str = anime["show"]["name"]
|
||||
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
|
||||
type = anime.get("__typename")
|
||||
normalized_anime = {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"availableEpisodesDetail": availableEpisodesDetail,
|
||||
"type": type,
|
||||
}
|
||||
return normalized_anime
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def _get_anime_episode(
|
||||
self, allanime_show_id: str, episode_string: str, translation_type: str = "sub"
|
||||
) -> "AllAnimeEpisode | dict":
|
||||
"""get the episode details and sources info
|
||||
|
||||
Args:
|
||||
allanime_show_id: [TODO:description]
|
||||
episode_string: [TODO:description]
|
||||
translation_type: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
variables = {
|
||||
"showId": allanime_show_id,
|
||||
"translationType": translation_type,
|
||||
"episodeString": episode_string,
|
||||
}
|
||||
try:
|
||||
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
|
||||
return episode["episode"]
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def get_episode_streams(self, anime, episode_number: str, translation_type="sub"):
|
||||
"""get the streams of an episode
|
||||
|
||||
Args:
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
anime: [TODO:description]
|
||||
episode_number: [TODO:description]
|
||||
|
||||
Yields:
|
||||
[TODO:description]
|
||||
"""
|
||||
anime_id = anime["id"]
|
||||
allanime_episode = self._get_anime_episode(
|
||||
anime_id, episode_number, translation_type
|
||||
)
|
||||
if not allanime_episode:
|
||||
return []
|
||||
|
||||
embeds = allanime_episode["sourceUrls"]
|
||||
try:
|
||||
for embed in embeds:
|
||||
try:
|
||||
# filter the working streams no need to get all since the others are mostly hsl
|
||||
# TODO: should i just get all the servers and handle the hsl??
|
||||
if embed.get("sourceName", "") not in (
|
||||
# priorities based on death note
|
||||
"Sak", # 7
|
||||
"S-mp4", # 7.9
|
||||
"Luf-mp4", # 7.7
|
||||
"Default", # 8.5
|
||||
"Yt-mp4", # 7.9
|
||||
"Kir", # NA
|
||||
# "Vid-mp4" # 4
|
||||
# "Ok", # 3.5
|
||||
# "Ss-Hls", # 5.5
|
||||
# "Mp4", # 4
|
||||
):
|
||||
continue
|
||||
url = embed.get("sourceUrl")
|
||||
#
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("--"):
|
||||
url = url[2:]
|
||||
url = one_digit_symmetric_xor(56, url)
|
||||
|
||||
if "tools.fast4speed.rsvp" in url:
|
||||
yield {
|
||||
"server": "Yt",
|
||||
"episode_title": f'{anime["title"]}; Episode {episode_number}',
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"links": [
|
||||
{
|
||||
"link": url,
|
||||
"quality": "1080",
|
||||
}
|
||||
],
|
||||
}
|
||||
continue
|
||||
|
||||
# get the stream url for an episode of the defined source names
|
||||
embed_url = (
|
||||
f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
|
||||
)
|
||||
resp = self.session.get(
|
||||
embed_url,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
match embed["sourceName"]:
|
||||
case "Luf-mp4":
|
||||
logger.debug("allanime:Found streams from gogoanime")
|
||||
yield {
|
||||
"server": "gogoanime",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Kir":
|
||||
logger.debug("allanime:Found streams from wetransfer")
|
||||
yield {
|
||||
"server": "wetransfer",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "S-mp4":
|
||||
logger.debug("allanime:Found streams from sharepoint")
|
||||
yield {
|
||||
"server": "sharepoint",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Sak":
|
||||
logger.debug("allanime:Found streams from dropbox")
|
||||
yield {
|
||||
"server": "dropbox",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Default":
|
||||
logger.debug("allanime:Found streams from wixmp")
|
||||
yield {
|
||||
"server": "wixmp",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
except Timeout:
|
||||
logger.error(
|
||||
"[ALLANIME-ERROR]: Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[ALLANIME-ERROR]: {e}")
|
||||
return []
|
||||
@@ -1,4 +0,0 @@
|
||||
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp", "Yt"]
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_REFERER = "https://allanime.to/"
|
||||
ALLANIME_API_ENDPOINT = "https://api.{}/api/".format(ALLANIME_BASE)
|
||||
@@ -1,56 +0,0 @@
|
||||
ALLANIME_SEARCH_GQL = """
|
||||
query (
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
ALLANIME_EPISODES_GQL = """\
|
||||
query (
|
||||
$showId: String!
|
||||
$translationType: VaildTranslationTypeEnumType!
|
||||
$episodeString: String!
|
||||
) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
episodeString
|
||||
sourceUrls
|
||||
notes
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ALLANIME_SHOW_GQL = """
|
||||
query ($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
_id
|
||||
name
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -1,73 +0,0 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class AllAnimeEpisodesInfo(TypedDict):
|
||||
dub: int
|
||||
sub: int
|
||||
raw: int
|
||||
|
||||
|
||||
class AllAnimePageInfo(TypedDict):
|
||||
total: int
|
||||
|
||||
|
||||
class AllAnimeShow(TypedDict):
|
||||
_id: str
|
||||
name: str
|
||||
availableEpisodesDetail: AllAnimeEpisodesInfo
|
||||
__typename: str
|
||||
|
||||
|
||||
class AllAnimeSearchResult(TypedDict):
|
||||
_id: str
|
||||
name: str
|
||||
availableEpisodes: list[str]
|
||||
__typename: str | None
|
||||
|
||||
|
||||
class AllAnimeShows(TypedDict):
|
||||
pageInfo: AllAnimePageInfo
|
||||
edges: list[AllAnimeSearchResult]
|
||||
|
||||
|
||||
class AllAnimeSearchResults(TypedDict):
|
||||
shows: AllAnimeShows
|
||||
|
||||
|
||||
class AllAnimeSourcesDownloads(TypedDict):
|
||||
sourceName: str
|
||||
dowloadUrl: str
|
||||
|
||||
|
||||
class AllAnimeSources(TypedDict):
|
||||
sourceUrl: str
|
||||
priority: float
|
||||
sandbox: str
|
||||
sourceName: str
|
||||
type: str
|
||||
className: str
|
||||
streamerId: str
|
||||
downloads: AllAnimeSourcesDownloads
|
||||
|
||||
|
||||
Server = Literal["gogoanime", "dropbox", "wetransfer", "sharepoint"]
|
||||
|
||||
|
||||
class AllAnimeEpisode(TypedDict):
|
||||
episodeString: str
|
||||
sourceUrls: list[AllAnimeSources]
|
||||
notes: str | None
|
||||
|
||||
|
||||
class AllAnimeStream:
|
||||
link: str
|
||||
mp4: bool
|
||||
hls: bool | None
|
||||
resolutionStr: str
|
||||
fromCache: str
|
||||
priority: int
|
||||
headers: dict | None
|
||||
|
||||
|
||||
class AllAnimeStreams:
|
||||
links: list[AllAnimeStream]
|
||||
@@ -1,237 +0,0 @@
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from yt_dlp.utils import (
|
||||
extract_attributes,
|
||||
get_element_by_id,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
from ..base_provider import AnimeProvider
|
||||
from .constants import (
|
||||
ANIMEPAHE_BASE,
|
||||
ANIMEPAHE_ENDPOINT,
|
||||
REQUEST_HEADERS,
|
||||
SERVER_HEADERS,
|
||||
)
|
||||
from .utils import process_animepahe_embed_page
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
|
||||
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KWIK_RE = re.compile(r"Player\|(.+?)'")
|
||||
|
||||
|
||||
# TODO: hack this to completion
|
||||
class AnimePaheApi(AnimeProvider):
|
||||
search_page: "AnimePaheSearchPage"
|
||||
anime: "AnimePaheAnimePage"
|
||||
HEADERS = REQUEST_HEADERS
|
||||
|
||||
def search_for_anime(self, user_query: str, *args):
|
||||
try:
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
|
||||
response = self.session.get(
|
||||
url,
|
||||
)
|
||||
if not response.ok:
|
||||
return
|
||||
data: "AnimePaheSearchPage" = response.json()
|
||||
self.search_page = data
|
||||
|
||||
return {
|
||||
"pageInfo": {
|
||||
"total": data["total"],
|
||||
"perPage": data["per_page"],
|
||||
"currentPage": data["current_page"],
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"availableEpisodes": list(range(result["episodes"])),
|
||||
"id": result["session"],
|
||||
"title": result["title"],
|
||||
"type": result["type"],
|
||||
"year": result["year"],
|
||||
"score": result["score"],
|
||||
"status": result["status"],
|
||||
"season": result["season"],
|
||||
"poster": result["poster"],
|
||||
}
|
||||
for result in data["data"]
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def get_anime(self, session_id: str, *args):
|
||||
page = 1
|
||||
try:
|
||||
anime_result: "AnimeSearchResult" = [
|
||||
anime
|
||||
for anime in self.search_page["data"]
|
||||
if anime["session"] == session_id
|
||||
][0]
|
||||
data: "AnimePaheAnimePage" = {} # pyright:ignore
|
||||
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
|
||||
|
||||
def _pages_loader(
|
||||
url,
|
||||
page,
|
||||
):
|
||||
response = self.session.get(
|
||||
url,
|
||||
)
|
||||
if response.ok:
|
||||
if not data:
|
||||
data.update(response.json())
|
||||
else:
|
||||
if ep_data := response.json().get("data"):
|
||||
data["data"].extend(ep_data)
|
||||
if response.json()["next_page_url"]:
|
||||
# TODO: Refine this
|
||||
time.sleep(
|
||||
random.choice(
|
||||
[
|
||||
0.25,
|
||||
0.1,
|
||||
0.5,
|
||||
0.75,
|
||||
1,
|
||||
]
|
||||
)
|
||||
)
|
||||
page += 1
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
|
||||
_pages_loader(
|
||||
url,
|
||||
page,
|
||||
)
|
||||
|
||||
_pages_loader(
|
||||
url,
|
||||
page,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
self.anime = data # pyright:ignore
|
||||
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
|
||||
title = ""
|
||||
return {
|
||||
"id": session_id,
|
||||
"title": anime_result["title"],
|
||||
"year": anime_result["year"],
|
||||
"season": anime_result["season"],
|
||||
"poster": anime_result["poster"],
|
||||
"score": anime_result["score"],
|
||||
"availableEpisodesDetail": {
|
||||
"sub": episodes,
|
||||
"dub": episodes,
|
||||
"raw": episodes,
|
||||
},
|
||||
"episodesInfo": [
|
||||
{
|
||||
"title": f"{episode['title'] or title};{episode['episode']}",
|
||||
"episode": episode["episode"],
|
||||
"id": episode["session"],
|
||||
"translation_type": episode["audio"],
|
||||
"duration": episode["duration"],
|
||||
"poster": episode["snapshot"],
|
||||
}
|
||||
for episode in data["data"]
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
|
||||
return {}
|
||||
|
||||
def get_episode_streams(self, anime, episode_number: str, translation_type, *args):
|
||||
try:
|
||||
# extract episode details from memory
|
||||
episode = [
|
||||
episode
|
||||
for episode in self.anime["data"]
|
||||
if float(episode["episode"]) == float(episode_number)
|
||||
]
|
||||
|
||||
if not episode:
|
||||
logger.error(
|
||||
f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist"
|
||||
)
|
||||
return []
|
||||
episode = episode[0]
|
||||
|
||||
anime_id = anime["id"]
|
||||
# fetch the episode page
|
||||
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
|
||||
response = self.session.get(url)
|
||||
# get the element containing links to juicy streams
|
||||
c = get_element_by_id("resolutionMenu", response.text)
|
||||
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
|
||||
# convert the elements containing embed links to a neat dict containing:
|
||||
# data-src
|
||||
# data-audio
|
||||
# data-resolution
|
||||
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
|
||||
|
||||
# get the episode title
|
||||
episode_title = (
|
||||
f"{episode['title'] or anime['title']}; Episode {episode['episode']}"
|
||||
)
|
||||
# get all links
|
||||
streams = {
|
||||
"server": "kwik",
|
||||
"links": [],
|
||||
"episode_title": episode_title,
|
||||
"subtitles": [],
|
||||
"headers": {},
|
||||
}
|
||||
for res_dict in res_dicts:
|
||||
# get embed url
|
||||
embed_url = res_dict["data-src"]
|
||||
data_audio = "dub" if res_dict["data-audio"] == "eng" else "sub"
|
||||
# filter streams by translation_type
|
||||
if data_audio != translation_type:
|
||||
continue
|
||||
|
||||
if not embed_url:
|
||||
logger.warn(
|
||||
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
|
||||
)
|
||||
return []
|
||||
# get embed page
|
||||
embed_response = self.session.get(
|
||||
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
|
||||
)
|
||||
if not response.ok:
|
||||
continue
|
||||
embed_page = embed_response.text
|
||||
|
||||
decoded_js = process_animepahe_embed_page(embed_page)
|
||||
if not decoded_js:
|
||||
logger.error("[ANIMEPAHE-ERROR]: failed to decode embed page")
|
||||
return
|
||||
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
|
||||
if not juicy_stream:
|
||||
logger.error("[ANIMEPAHE-ERROR]: failed to find juicy stream")
|
||||
return
|
||||
juicy_stream = juicy_stream.group(1)
|
||||
# add the link
|
||||
streams["links"].append(
|
||||
{
|
||||
"quality": res_dict["data-resolution"],
|
||||
"translation_type": data_audio,
|
||||
"link": juicy_stream,
|
||||
}
|
||||
)
|
||||
yield streams
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
|
||||
@@ -1,61 +0,0 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class AnimeSearchResult(TypedDict):
|
||||
id: int
|
||||
title: str
|
||||
type: str
|
||||
episodes: int
|
||||
status: str
|
||||
season: str
|
||||
year: int
|
||||
score: int
|
||||
poster: str
|
||||
session: str
|
||||
|
||||
|
||||
class AnimePaheSearchPage(TypedDict):
|
||||
total: int
|
||||
per_page: int
|
||||
current_page: int
|
||||
last_page: int
|
||||
_from: int
|
||||
to: int
|
||||
data: list[AnimeSearchResult]
|
||||
|
||||
|
||||
class Episode(TypedDict):
|
||||
id: int
|
||||
anime_id: int
|
||||
episode: int
|
||||
episode2: int
|
||||
edition: str
|
||||
title: str
|
||||
snapshot: str # episode image
|
||||
disc: str
|
||||
audio: Literal["eng", "jpn"]
|
||||
duration: str # time 00:00:00
|
||||
session: str
|
||||
filler: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class AnimePaheAnimePage(TypedDict):
|
||||
total: int
|
||||
per_page: int
|
||||
current_page: int
|
||||
last_page: int
|
||||
next_page_url: str | None
|
||||
prev_page_url: str | None
|
||||
_from: int
|
||||
to: int
|
||||
data: list[Episode]
|
||||
|
||||
|
||||
class Server:
|
||||
type: str
|
||||
data_src = "https://kwik.si/e/PImJ0u7Y3M0G"
|
||||
data_fansub: str
|
||||
data_resolution: Literal["360", "720", "1080"]
|
||||
data_audio: Literal["eng", "jpn"]
|
||||
data_av1: str
|
||||
@@ -1,236 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from itertools import cycle
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from yt_dlp.utils import (
|
||||
clean_html,
|
||||
extract_attributes,
|
||||
get_element_by_class,
|
||||
get_element_html_by_class,
|
||||
get_elements_by_class,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
from ..base_provider import AnimeProvider
|
||||
from ..utils import give_random_quality
|
||||
from .constants import SERVERS_AVAILABLE
|
||||
from .types import AniWatchStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
|
||||
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
|
||||
|
||||
|
||||
class ParseAnchorAndImgTag(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.img_tag = None
|
||||
self.a_tag = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "img":
|
||||
self.img_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
if tag == "a":
|
||||
self.a_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
|
||||
|
||||
class AniWatchApi(AnimeProvider):
|
||||
# HEADERS = {"Referer": "https://hianime.to/home"}
|
||||
|
||||
def search_for_anime(self, anime_title: str, *args):
|
||||
try:
|
||||
query = quote_plus(anime_title)
|
||||
url = f"https://hianime.to/search?keyword={query}"
|
||||
response = self.session.get(url)
|
||||
if not response.ok:
|
||||
return
|
||||
search_page = response.text
|
||||
search_results_html_items = get_elements_by_class("flw-item", search_page)
|
||||
results = []
|
||||
for search_results_html_item in search_results_html_items:
|
||||
film_poster_html = get_element_by_class(
|
||||
"film-poster", search_results_html_item
|
||||
)
|
||||
|
||||
if not film_poster_html:
|
||||
continue
|
||||
# get availableEpisodes
|
||||
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
|
||||
episodes = clean_html(episodes_html) or 12
|
||||
|
||||
# get anime id and poster image url
|
||||
parser = ParseAnchorAndImgTag()
|
||||
parser.feed(film_poster_html)
|
||||
image_data = parser.img_tag
|
||||
anime_link_data = parser.a_tag
|
||||
if not image_data or not anime_link_data:
|
||||
continue
|
||||
|
||||
episodes = int(episodes)
|
||||
|
||||
# finally!!
|
||||
image_link = image_data["data-src"]
|
||||
anime_id = anime_link_data["data-id"]
|
||||
title = anime_link_data["title"]
|
||||
|
||||
results.append(
|
||||
{
|
||||
"availableEpisodes": list(range(1, episodes)),
|
||||
"id": anime_id,
|
||||
"title": title,
|
||||
"poster": image_link,
|
||||
}
|
||||
)
|
||||
self.search_results = results
|
||||
return {"pageInfo": {}, "results": results}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIWATCH-ERROR]: {e}")
|
||||
|
||||
def get_anime(self, aniwatch_id, *args):
|
||||
try:
|
||||
anime_result = {}
|
||||
for anime in self.search_results:
|
||||
if anime["id"] == aniwatch_id:
|
||||
anime_result = anime
|
||||
break
|
||||
anime_url = f"https://hianime.to/ajax/v2/episode/list/{aniwatch_id}"
|
||||
response = self.session.get(anime_url, timeout=10)
|
||||
if response.ok:
|
||||
response_json = response.json()
|
||||
aniwatch_anime_page = response_json["html"]
|
||||
episodes_info_container_html = get_element_html_by_class(
|
||||
"ss-list", aniwatch_anime_page
|
||||
)
|
||||
episodes_info_html_list = get_elements_html_by_class(
|
||||
"ep-item", episodes_info_container_html
|
||||
)
|
||||
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
|
||||
episodes_info_dicts = [
|
||||
extract_attributes(episode_dict)
|
||||
for episode_dict in episodes_info_html_list
|
||||
]
|
||||
episodes = [episode["data-number"] for episode in episodes_info_dicts]
|
||||
self.episodes_info = [
|
||||
{
|
||||
"id": episode["data-id"],
|
||||
"title": (
|
||||
(episode["title"] or "").replace(
|
||||
f"Episode {episode['data-number']}", ""
|
||||
)
|
||||
or anime_result["title"]
|
||||
)
|
||||
+ f"; Episode {episode['data-number']}",
|
||||
"episode": episode["data-number"],
|
||||
}
|
||||
for episode in episodes_info_dicts
|
||||
]
|
||||
return {
|
||||
"id": aniwatch_id,
|
||||
"availableEpisodesDetail": {
|
||||
"dub": episodes,
|
||||
"sub": episodes,
|
||||
"raw": episodes,
|
||||
},
|
||||
"poster": anime_result["poster"],
|
||||
"title": anime_result["title"],
|
||||
"episodes_info": self.episodes_info,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIWACTCH-ERROR]: {e}")
|
||||
|
||||
def get_episode_streams(self, anime, episode, translation_type, *args):
|
||||
try:
|
||||
episode_details = [
|
||||
episode_details
|
||||
for episode_details in self.episodes_info
|
||||
if episode_details["episode"] == episode
|
||||
]
|
||||
if not episode_details:
|
||||
return
|
||||
episode_details = episode_details[0]
|
||||
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
|
||||
response = self.session.get(episode_url)
|
||||
if response.ok:
|
||||
response_json = response.json()
|
||||
episode_page_html = response_json["html"]
|
||||
servers_containers_html = get_elements_html_by_class(
|
||||
"ps__-list", episode_page_html
|
||||
)
|
||||
if not servers_containers_html:
|
||||
return
|
||||
# sub servers
|
||||
try:
|
||||
servers_html_sub = get_elements_html_by_class(
|
||||
"server-item", servers_containers_html[0]
|
||||
)
|
||||
except Exception:
|
||||
logger.warn("AniWatch: sub not found")
|
||||
servers_html_sub = None
|
||||
|
||||
# dub servers
|
||||
try:
|
||||
servers_html_dub = get_elements_html_by_class(
|
||||
"server-item", servers_containers_html[1]
|
||||
)
|
||||
except Exception:
|
||||
logger.warn("AniWatch: dub not found")
|
||||
servers_html_dub = None
|
||||
|
||||
if translation_type == "dub":
|
||||
servers_html = servers_html_dub
|
||||
else:
|
||||
servers_html = servers_html_sub
|
||||
if not servers_html:
|
||||
return
|
||||
for server_name, server_html in zip(
|
||||
cycle(SERVERS_AVAILABLE), servers_html
|
||||
):
|
||||
try:
|
||||
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
|
||||
servers_info = extract_attributes(server_html)
|
||||
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
|
||||
embed_response = self.session.get(embed_url)
|
||||
if embed_response.ok:
|
||||
embed_json = embed_response.json()
|
||||
raw_link_to_streams = embed_json["link"]
|
||||
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
|
||||
if not match:
|
||||
continue
|
||||
provider_domain = match.group(1)
|
||||
embed_type = match.group(2)
|
||||
episode_number = match.group(3)
|
||||
source_id = match.group(4)
|
||||
|
||||
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
|
||||
link_to_streams_response = self.session.get(link_to_streams)
|
||||
if link_to_streams_response.ok:
|
||||
juicy_streams_json: "AniWatchStream" = (
|
||||
link_to_streams_response.json()
|
||||
)
|
||||
yield {
|
||||
"headers": {},
|
||||
"subtitles": [
|
||||
{
|
||||
"url": track["file"],
|
||||
"language": track["label"],
|
||||
}
|
||||
for track in juicy_streams_json["tracks"]
|
||||
if track["kind"] == "captions"
|
||||
],
|
||||
"server": server_name,
|
||||
"episode_title": episode_details["title"],
|
||||
"links": give_random_quality(
|
||||
[
|
||||
{"link": link["file"], "type": link["type"]}
|
||||
for link in juicy_streams_json["sources"]
|
||||
]
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIWATCH_ERROR]: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[ANIWATCH_ERROR]: {e}")
|
||||
@@ -1 +0,0 @@
|
||||
SERVERS_AVAILABLE = ["HD1", "HD2", "StreamSB", "StreamTape"]
|
||||
@@ -1,26 +0,0 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class AniWatchSkipTime(TypedDict):
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
class AniWatchSource(TypedDict):
|
||||
file: str
|
||||
type: str
|
||||
|
||||
|
||||
class AniWatchTrack(TypedDict):
|
||||
file: str
|
||||
label: str
|
||||
kind: Literal["captions", "thumbnails", "audio"]
|
||||
|
||||
|
||||
class AniWatchStream(TypedDict):
|
||||
sources: list[AniWatchSource]
|
||||
tracks: list[AniWatchTrack]
|
||||
encrypted: bool
|
||||
intro: AniWatchSkipTime
|
||||
outro: AniWatchSkipTime
|
||||
server: int
|
||||
@@ -1,65 +0,0 @@
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from yt_dlp.utils import clean_html, get_element_by_class, get_elements_by_class
|
||||
|
||||
from ..base_provider import AnimeProvider
|
||||
from .constants import ANIWAVE_BASE, SEARCH_HEADERS
|
||||
|
||||
|
||||
class ParseAnchorAndImgTag(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.img_tag = None
|
||||
self.a_tag = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "img":
|
||||
self.img_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
if tag == "a":
|
||||
self.a_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
|
||||
|
||||
class AniWaveApi(AnimeProvider):
|
||||
def search_for_anime(self, anime_title, *args):
|
||||
self.session.headers.update(SEARCH_HEADERS)
|
||||
search_url = f"{ANIWAVE_BASE}/filter"
|
||||
params = {"keyword": anime_title}
|
||||
res = self.session.get(search_url, params=params)
|
||||
search_page = res.text
|
||||
search_results_html_list = get_elements_by_class("item", search_page)
|
||||
results = []
|
||||
for result_html in search_results_html_list:
|
||||
aniposter_html = get_element_by_class("poster", result_html)
|
||||
episode_html = get_element_by_class("sub", aniposter_html)
|
||||
episodes = clean_html(episode_html) or 12
|
||||
if not aniposter_html:
|
||||
return
|
||||
parser = ParseAnchorAndImgTag()
|
||||
parser.feed(aniposter_html)
|
||||
image_data = parser.img_tag
|
||||
anime_link_data = parser.a_tag
|
||||
if not image_data or not anime_link_data:
|
||||
continue
|
||||
|
||||
episodes = int(episodes)
|
||||
|
||||
# finally!!
|
||||
image_link = image_data["src"]
|
||||
title = image_data["alt"]
|
||||
anime_id = anime_link_data["href"]
|
||||
|
||||
results.append(
|
||||
{
|
||||
"availableEpisodes": list(range(1, episodes)),
|
||||
"id": anime_id,
|
||||
"title": title,
|
||||
"poster": image_link,
|
||||
}
|
||||
)
|
||||
self.search_results = results
|
||||
return {"pageInfo": {}, "results": results}
|
||||
|
||||
def get_anime(self, anime_id, *args):
|
||||
anime_page_url = f"{ANIWAVE_BASE}{anime_id}"
|
||||
self.session.get(anime_page_url)
|
||||
# TODO: to be continued; mostly js so very difficult
|
||||
@@ -1,20 +0,0 @@
|
||||
ANIWAVE_BASE = "https://aniwave.to"
|
||||
|
||||
SEARCH_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
# 'Accept-Encoding': 'Utf-8',
|
||||
"Referer": "https://aniwave.to/filter",
|
||||
"DNT": "1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Connection": "keep-alive",
|
||||
"Alt-Used": "aniwave.to",
|
||||
# 'Cookie': '__pf=1; usertype=guest; session=BElk9DJdO3sFdDmLiGxuNiM9eGYO1TjktGsmdwjV',
|
||||
"Priority": "u=0, i",
|
||||
# Requests doesn't support trailers
|
||||
# 'TE': 'trailers',
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import requests
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
|
||||
class AnimeProvider:
|
||||
session: requests.Session
|
||||
|
||||
USER_AGENT = random_user_agent()
|
||||
HEADERS = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session = requests.session()
|
||||
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
|
||||
@@ -1,15 +0,0 @@
|
||||
import logging
|
||||
|
||||
from requests import get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_anime_info_from_bal(anilist_id):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{anilist_id}.json"
|
||||
response = get(url, timeout=11)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
@@ -1,89 +0,0 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class PageInfo(TypedDict):
|
||||
total: int
|
||||
perPage: int
|
||||
currentPage: int
|
||||
|
||||
|
||||
#
|
||||
# class EpisodesDetail(TypedDict):
|
||||
# dub: int
|
||||
# sub: int
|
||||
# raw: int
|
||||
#
|
||||
|
||||
|
||||
# search data
|
||||
class SearchResult(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
availableEpisodes: list[str]
|
||||
type: str
|
||||
score: int
|
||||
status: str
|
||||
season: str
|
||||
poster: str
|
||||
|
||||
|
||||
class SearchResults(TypedDict):
|
||||
pageInfo: PageInfo
|
||||
results: list[SearchResult]
|
||||
|
||||
|
||||
# anime data
|
||||
class AnimeEpisodeDetails(TypedDict):
|
||||
dub: list[str]
|
||||
sub: list[str]
|
||||
raw: list[str]
|
||||
|
||||
|
||||
#
|
||||
# class AnimeEpisode(TypedDict):
|
||||
# id: str
|
||||
# title: str
|
||||
#
|
||||
|
||||
|
||||
class AnimeEpisodeInfo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
episode: str
|
||||
poster: str | None
|
||||
duration: str | None
|
||||
translation_type: str | None
|
||||
|
||||
|
||||
class Anime(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
availableEpisodesDetail: AnimeEpisodeDetails
|
||||
type: str | None
|
||||
episodesInfo: list[AnimeEpisodeInfo] | None
|
||||
poster: str
|
||||
year: str
|
||||
|
||||
|
||||
class EpisodeStream(TypedDict):
|
||||
resolution: str | None
|
||||
link: str
|
||||
hls: bool | None
|
||||
mp4: bool | None
|
||||
priority: int | None
|
||||
quality: Literal["360", "720", "1080", "unknown"]
|
||||
translation_type: Literal["dub", "sub"]
|
||||
|
||||
|
||||
class Subtitle(TypedDict):
|
||||
url: str
|
||||
language: str
|
||||
|
||||
|
||||
class Server(TypedDict):
|
||||
headers: dict
|
||||
subtitles: list[Subtitle]
|
||||
audio: list
|
||||
server: str
|
||||
episode_title: str
|
||||
links: list[EpisodeStream]
|
||||
@@ -1,15 +0,0 @@
|
||||
import logging
|
||||
|
||||
from requests import get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_anime_info_from_bal(anilist_id):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{anilist_id}.json"
|
||||
response = get(url, timeout=11)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
@@ -1,286 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from requests import post
|
||||
from thefuzz import fuzz
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..anilist.types import AnilistDataSchema
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
"""
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
episodes
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def search_for_manga_with_anilist(manga_title: str):
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
currentPage
|
||||
}
|
||||
media(search: $query, type: MANGA) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
chapters
|
||||
status
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": {"query": manga_title}},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
return {
|
||||
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
|
||||
"results": [
|
||||
{
|
||||
"id": anime_result["id"],
|
||||
"poster": anime_result["coverImage"]["large"],
|
||||
"title": (
|
||||
anime_result["title"]["romaji"]
|
||||
or anime_result["title"]["english"]
|
||||
)
|
||||
+ f" [Chapters: {anime_result['chapters']}]",
|
||||
"type": "manga",
|
||||
"availableChapters": list(
|
||||
range(
|
||||
1,
|
||||
(
|
||||
anime_result["chapters"]
|
||||
if anime_result["chapters"]
|
||||
else 0
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
for anime_result in anilist_data["data"]["Page"]["media"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def search_for_anime_with_anilist(anime_title: str):
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
episodes
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": {"query": anime_title}},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
return {
|
||||
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
|
||||
"results": [
|
||||
{
|
||||
"id": anime_result["id"],
|
||||
"title": anime_result["title"]["romaji"]
|
||||
or anime_result["title"]["english"],
|
||||
"type": "anime",
|
||||
"availableEpisodes": list(
|
||||
range(
|
||||
1,
|
||||
(
|
||||
anime_result["episodes"]
|
||||
if not anime_result["status"] == "RELEASING"
|
||||
and anime_result["episodes"]
|
||||
else (
|
||||
anime_result["nextAiringEpisode"]["episode"] - 1
|
||||
if anime_result["nextAiringEpisode"]
|
||||
else 0
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
for anime_result in anilist_data["data"]["Page"]["media"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_mal_id_and_anilist_id(anime_title: str) -> "dict[str,int] | None":
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
variables = {"query": anime_title}
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
if response.status_code == 200:
|
||||
anime = max(
|
||||
anilist_data["data"]["Page"]["media"],
|
||||
key=lambda anime: max(
|
||||
(
|
||||
fuzz.ratio(anime, str(anime["title"]["romaji"])),
|
||||
fuzz.ratio(anime_title, str(anime["title"]["english"])),
|
||||
)
|
||||
),
|
||||
)
|
||||
return {"id_anilist": anime["id"], "id_mal": anime["idMal"]}
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
|
||||
|
||||
def get_basic_anime_info_by_title(anime_title: str):
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
streamingEpisodes {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from ...Utility.data import anime_normalizer
|
||||
|
||||
# normalize the title
|
||||
anime_title = anime_normalizer.get(anime_title, anime_title)
|
||||
try:
|
||||
variables = {"query": anime_title}
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
if response.status_code == 200:
|
||||
anime = max(
|
||||
anilist_data["data"]["Page"]["media"],
|
||||
key=lambda anime: max(
|
||||
(
|
||||
fuzz.ratio(
|
||||
anime_title.lower(), str(anime["title"]["romaji"]).lower()
|
||||
),
|
||||
fuzz.ratio(
|
||||
anime_title.lower(), str(anime["title"]["english"]).lower()
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
return {
|
||||
"idAnilist": anime["id"],
|
||||
"idMal": anime["idMal"],
|
||||
"title": {
|
||||
"english": anime["title"]["english"],
|
||||
"romaji": anime["title"]["romaji"],
|
||||
},
|
||||
"episodes": [
|
||||
{"title": episode["title"]}
|
||||
for episode in anime["streamingEpisodes"]
|
||||
if episode
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
@@ -1,192 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Callable, List
|
||||
|
||||
from click import clear
|
||||
from rich import print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FZF_DEFAULT_OPTS = """
|
||||
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
|
||||
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
|
||||
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
|
||||
--color=border:#262626,label:#aeaeae,query:#d9d9d9
|
||||
--border="rounded" --border-label="" --preview-window="border-rounded" --prompt="> "
|
||||
--marker=">" --pointer="◆" --separator="─" --scrollbar="│"
|
||||
"""
|
||||
|
||||
HEADER = """
|
||||
|
||||
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
|
||||
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
|
||||
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
|
||||
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
|
||||
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
|
||||
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class FZF:
|
||||
"""an abstraction over the fzf commandline utility
|
||||
|
||||
Attributes:
|
||||
FZF_EXECUTABLE: [TODO:attribute]
|
||||
default_options: [TODO:attribute]
|
||||
stdout: [TODO:attribute]
|
||||
"""
|
||||
|
||||
if not os.getenv("FZF_DEFAULT_OPTS"):
|
||||
os.environ["FZF_DEFAULT_OPTS"] = FZF_DEFAULT_OPTS
|
||||
FZF_EXECUTABLE = shutil.which("fzf")
|
||||
default_options = [
|
||||
"--cycle",
|
||||
"--info=hidden",
|
||||
"--layout=reverse",
|
||||
"--height=100%",
|
||||
"--bind=right:accept",
|
||||
"--no-margin",
|
||||
"+m",
|
||||
"-i",
|
||||
"--exact",
|
||||
"--tabstop=1",
|
||||
"--preview-window=left,35%,wrap",
|
||||
"--wrap",
|
||||
]
|
||||
|
||||
def _with_filter(self, command: str, work: Callable) -> List[str]:
|
||||
"""ported from the fzf docs demo
|
||||
|
||||
Args:
|
||||
command: [TODO:description]
|
||||
work: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
shell=True,
|
||||
)
|
||||
except subprocess.SubprocessError as e:
|
||||
print(f"Failed to start subprocess: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = process.stdin
|
||||
|
||||
try:
|
||||
work()
|
||||
if process.stdin:
|
||||
process.stdin.close()
|
||||
except Exception as e:
|
||||
print(f"Exception during work execution: {e}", file=sys.stderr)
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
|
||||
output = []
|
||||
if process.stdout:
|
||||
output = process.stdout.read().splitlines()
|
||||
process.stdout.close()
|
||||
|
||||
return output
|
||||
|
||||
def _run_fzf(self, commands: list[str], _fzf_input) -> str:
|
||||
"""core abstraction
|
||||
|
||||
Args:
|
||||
_fzf_input ([TODO:parameter]): [TODO:description]
|
||||
commands: [TODO:description]
|
||||
|
||||
Raises:
|
||||
Exception: [TODO:throw]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
fzf_input = "\n".join(_fzf_input)
|
||||
|
||||
if not self.FZF_EXECUTABLE:
|
||||
raise Exception("fzf executable not found")
|
||||
|
||||
result = subprocess.run(
|
||||
[self.FZF_EXECUTABLE, *commands],
|
||||
input=fzf_input,
|
||||
stdout=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
text=True,
|
||||
encoding="utf-8"
|
||||
)
|
||||
if not result or result.returncode != 0 or not result.stdout:
|
||||
print("sth went wrong:confused:")
|
||||
input("press enter to try again...")
|
||||
clear()
|
||||
return self._run_fzf(commands, _fzf_input)
|
||||
clear()
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
def run(
|
||||
self,
|
||||
fzf_input: list[str],
|
||||
prompt: str,
|
||||
header: str = HEADER,
|
||||
preview: str | None = None,
|
||||
expect: str | None = None,
|
||||
validator: Callable | None = None,
|
||||
) -> str:
|
||||
"""a helper method that wraps common use cases over the fzf utility
|
||||
|
||||
Args:
|
||||
fzf_input: [TODO:description]
|
||||
prompt: [TODO:description]
|
||||
header: [TODO:description]
|
||||
preview: [TODO:description]
|
||||
expect: [TODO:description]
|
||||
validator: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
_commands = [
|
||||
*self.default_options,
|
||||
"--header",
|
||||
HEADER,
|
||||
"--header-first",
|
||||
"--prompt",
|
||||
prompt.title(),
|
||||
] # pyright:ignore
|
||||
|
||||
if preview:
|
||||
_commands.append(f"--preview={preview}")
|
||||
if expect:
|
||||
_commands.append(f"--expect={expect}")
|
||||
|
||||
result = self._run_fzf(_commands, fzf_input) # pyright:ignore
|
||||
if not result:
|
||||
print("Please enter a value")
|
||||
input("Enter to do it right")
|
||||
return self.run(fzf_input, prompt, header, preview, expect, validator)
|
||||
elif validator:
|
||||
success, info = validator(result)
|
||||
if not success:
|
||||
print(info)
|
||||
input("Enter to try again")
|
||||
return self.run(fzf_input, prompt, header, preview, expect, validator)
|
||||
return result
|
||||
|
||||
|
||||
fzf = FZF()
|
||||
|
||||
if __name__ == "__main__":
|
||||
action = fzf.run([*os.listdir(), "exit"], "Prompt: ", "Header", preview="bat {}")
|
||||
print(action)
|
||||
@@ -1,13 +0,0 @@
|
||||
import requests
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
|
||||
class MangaProvider:
|
||||
session: requests.Session
|
||||
|
||||
USER_AGENT = random_user_agent()
|
||||
HEADERS = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session = requests.session()
|
||||
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
|
||||
@@ -1,138 +0,0 @@
|
||||
import subprocess
|
||||
from shutil import which
|
||||
from sys import exit
|
||||
|
||||
from plyer import notification
|
||||
|
||||
from fastanime import APP_NAME
|
||||
|
||||
from ...constants import ICON_PATH
|
||||
|
||||
|
||||
class RofiApi:
|
||||
ROFI_EXECUTABLE = which("rofi")
|
||||
|
||||
rofi_theme = ""
|
||||
rofi_theme_confirm = ""
|
||||
rofi_theme_input = ""
|
||||
|
||||
def run_with_icons(self, options: list[str], prompt_text: str) -> str:
|
||||
rofi_input = "\n".join(options)
|
||||
|
||||
if not self.ROFI_EXECUTABLE:
|
||||
raise Exception("Rofi not found")
|
||||
|
||||
args = [self.ROFI_EXECUTABLE]
|
||||
if self.rofi_theme:
|
||||
args.extend(["-no-config", "-theme", self.rofi_theme])
|
||||
args.extend(["-p", prompt_text, "-i", "-show-icons", "-dmenu"])
|
||||
result = subprocess.run(
|
||||
args,
|
||||
input=rofi_input,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
choice = result.stdout.strip()
|
||||
if not choice:
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
message="FastAnime is shutting down",
|
||||
title="No Valid Input Provided",
|
||||
) # pyright:ignore
|
||||
exit(1)
|
||||
|
||||
return choice
|
||||
|
||||
def run(self, options: list[str], prompt_text: str) -> str:
|
||||
rofi_input = "\n".join(options)
|
||||
|
||||
if not self.ROFI_EXECUTABLE:
|
||||
raise Exception("Rofi not found")
|
||||
|
||||
args = [self.ROFI_EXECUTABLE]
|
||||
if self.rofi_theme:
|
||||
args.extend(["-no-config", "-theme", self.rofi_theme])
|
||||
args.extend(["-p", prompt_text, "-i", "-dmenu"])
|
||||
result = subprocess.run(
|
||||
args,
|
||||
input=rofi_input,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
choice = result.stdout.strip()
|
||||
if not choice or choice not in options:
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
message="FastAnime is shutting down",
|
||||
title="No Valid Input Provided",
|
||||
) # pyright:ignore
|
||||
exit(1)
|
||||
|
||||
return choice
|
||||
|
||||
def confirm(self, prompt_text: str) -> bool:
|
||||
rofi_choices = "Yes\nNo"
|
||||
if not self.ROFI_EXECUTABLE:
|
||||
raise Exception("Rofi not found")
|
||||
args = [self.ROFI_EXECUTABLE]
|
||||
if self.rofi_theme_confirm:
|
||||
args.extend(["-no-config", "-theme", self.rofi_theme_confirm])
|
||||
args.extend(["-p", prompt_text, "-i", "", "-no-fixed-num-lines", "-dmenu"])
|
||||
result = subprocess.run(
|
||||
args,
|
||||
input=rofi_choices,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
choice = result.stdout.strip()
|
||||
if not choice:
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
message="FastAnime is shutting down",
|
||||
title="No Valid Input Provided",
|
||||
) # pyright:ignore
|
||||
exit(1)
|
||||
if choice == "Yes":
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def ask(
|
||||
self, prompt_text: str, is_int: bool = False, is_float: bool = False
|
||||
) -> str | float | int:
|
||||
if not self.ROFI_EXECUTABLE:
|
||||
raise Exception("Rofi not found")
|
||||
args = [self.ROFI_EXECUTABLE]
|
||||
if self.rofi_theme_input:
|
||||
args.extend(["-no-config", "-theme", self.rofi_theme_input])
|
||||
args.extend(["-p", prompt_text, "-i", "-no-fixed-num-lines", "-dmenu"])
|
||||
result = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
user_input = result.stdout.strip()
|
||||
if not user_input:
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
message="FastAnime is shutting down",
|
||||
title="No Valid Input Provided",
|
||||
) # pyright:ignore
|
||||
exit(1)
|
||||
if is_float:
|
||||
user_input = float(user_input)
|
||||
elif is_int:
|
||||
user_input = int(user_input)
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
Rofi = RofiApi()
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1753345091,
|
||||
"narHash": "sha256-CdX2Rtvp5I8HGu9swBmYuq+ILwRxpXdJwlpg8jvN4tU=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
104
flake.nix
Normal file
104
flake.nix
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
description = "Viu Project Flake";
|
||||
|
||||
inputs = {
|
||||
# The nixpkgs unstable latest commit breaks the plyer python package
|
||||
nixpkgs.url = "github:nixos/nixpkgs/3ff0e34b1383648053bba8ed03f201d3466f90c9";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
inherit (pkgs) lib python3Packages;
|
||||
|
||||
version = "3.1.0";
|
||||
in
|
||||
{
|
||||
packages.default = python3Packages.buildPythonApplication {
|
||||
pname = "viu";
|
||||
inherit version;
|
||||
pyproject = true;
|
||||
|
||||
src = self;
|
||||
|
||||
build-system = with python3Packages; [ hatchling ];
|
||||
|
||||
dependencies = with python3Packages; [
|
||||
click
|
||||
inquirerpy
|
||||
requests
|
||||
rich
|
||||
thefuzz
|
||||
yt-dlp
|
||||
dbus-python
|
||||
hatchling
|
||||
plyer
|
||||
mpv
|
||||
fastapi
|
||||
pycryptodome
|
||||
pypresence
|
||||
httpx
|
||||
];
|
||||
|
||||
postPatch = ''
|
||||
substituteInPlace pyproject.toml \
|
||||
--replace-fail "pydantic>=2.11.7" "pydantic>=2.11.4"
|
||||
'';
|
||||
|
||||
makeWrapperArgs = [
|
||||
"--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
with pkgs;
|
||||
[
|
||||
mpv
|
||||
]
|
||||
)
|
||||
}"
|
||||
];
|
||||
|
||||
# Needs to be adapted for the nix derivation build
|
||||
doCheck = false;
|
||||
|
||||
pythonImportsCheck = [ "viu" ];
|
||||
|
||||
meta = {
|
||||
description = "Your browser anime experience from the terminal";
|
||||
homepage = "https://github.com/Benexl/Viu";
|
||||
changelog = "https://github.com/Benexl/Viu/releases/tag/v${version}";
|
||||
mainProgram = "viu";
|
||||
license = lib.licenses.unlicense;
|
||||
maintainers = with lib.maintainers; [ theobori ];
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
venvDir = ".venv";
|
||||
|
||||
env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libxcrypt-legacy ];
|
||||
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
mpv
|
||||
fzf
|
||||
rofi
|
||||
uv
|
||||
pyright
|
||||
]
|
||||
++ (with python3Packages; [
|
||||
venvShellHook
|
||||
hatchling
|
||||
])
|
||||
++ self.packages.${system}.default.dependencies;
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
APP_DIR="$(
|
||||
cd -- "$(dirname "$0")" >/dev/null 2>&1
|
||||
pwd -P
|
||||
)"
|
||||
|
||||
# fish shell completions
|
||||
_FASTANIME_COMPLETE=fish_source fastanime >"$APP_DIR/completions/fastanime.fish"
|
||||
|
||||
# zsh completions
|
||||
_FASTANIME_COMPLETE=zsh_source fastanime >"$APP_DIR/completions/fastanime.zsh"
|
||||
|
||||
# bash completions
|
||||
_FASTANIME_COMPLETE=bash_source fastanime >"$APP_DIR/completions/fastanime.bash"
|
||||
1373
poetry.lock
generated
1373
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user