Compare commits
1533 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8f4faa10 | ||
|
|
ddc0b7a481 | ||
|
|
7eec3ece49 | ||
|
|
789c35e2b5 | ||
|
|
8870782a6e | ||
|
|
e75ffb603c | ||
|
|
feabc87c9f | ||
|
|
6f1a45ffb1 | ||
|
|
098db97cba | ||
|
|
597da56975 | ||
|
|
325bdf5cba | ||
|
|
db2f249979 | ||
|
|
b61b8635b8 | ||
|
|
5aafc21bda | ||
|
|
099c70a13b | ||
|
|
43708ee2a3 | ||
|
|
cfb15c1a78 | ||
|
|
e9a95d7ada | ||
|
|
a21a111221 | ||
|
|
18e504faf2 | ||
|
|
9ffe2098a5 | ||
|
|
1315e836a4 | ||
|
|
2e4289e75c | ||
|
|
96e73a3a53 | ||
|
|
a369be0f4a | ||
|
|
d5676e5173 | ||
|
|
44c4cf93e2 | ||
|
|
02ac590caa | ||
|
|
a466c02304 | ||
|
|
e74c26fe36 | ||
|
|
b1267cba83 | ||
|
|
91bb0ed9c0 | ||
|
|
4a145ee7cb | ||
|
|
463019ce5a | ||
|
|
9a9d35cac4 | ||
|
|
f41ecd24c5 | ||
|
|
eced8200c1 | ||
|
|
669bc6a620 | ||
|
|
37df9b65c7 | ||
|
|
6721d01fa6 | ||
|
|
2b49af9620 | ||
|
|
2f62898a10 | ||
|
|
832259ce48 | ||
|
|
b8ccce250a | ||
|
|
aa04ecff4f | ||
|
|
dcf97d3d24 | ||
|
|
879ad52b32 | ||
|
|
0bedc3ee93 | ||
|
|
1657c55cbe | ||
|
|
8b1324139d | ||
|
|
04124e3dad | ||
|
|
9c26357f76 | ||
|
|
7133d6b441 | ||
|
|
6bc0111d0a | ||
|
|
1188e66f37 | ||
|
|
ef6d3e868d | ||
|
|
d677f9579e | ||
|
|
0b920e87ae | ||
|
|
4d5aa4ad2f | ||
|
|
4b63c2f536 | ||
|
|
31ad9424f5 | ||
|
|
45f4ccfd93 | ||
|
|
285e2042ae | ||
|
|
e4b7f8ce38 | ||
|
|
6892cbbc19 | ||
|
|
58ea256b44 | ||
|
|
aa475c58aa | ||
|
|
8247314d01 | ||
|
|
2826ca4a43 | ||
|
|
64ffc18da7 | ||
|
|
21fde5e068 | ||
|
|
ea9ed6c238 | ||
|
|
8eaed07cff | ||
|
|
4d111aff82 | ||
|
|
7236bea29a | ||
|
|
5165c3e34a | ||
|
|
572b23169c | ||
|
|
e1fce06f97 | ||
|
|
446d5b7949 | ||
|
|
17c0310220 | ||
|
|
1b0be84972 | ||
|
|
2df68fa83c | ||
|
|
4184736bee | ||
|
|
81a5f15600 | ||
|
|
4a4a274259 | ||
|
|
0776cea7bc | ||
|
|
fb853e6c73 | ||
|
|
57d8b060d2 | ||
|
|
6d1810946b | ||
|
|
88f230c3f4 | ||
|
|
e9eddf06fb | ||
|
|
8af7a3caf4 | ||
|
|
ad7f1f05b0 | ||
|
|
e1fe8d1e29 | ||
|
|
f8f01ac27f | ||
|
|
8e79cba7d5 | ||
|
|
87e457401d | ||
|
|
bb271e276c | ||
|
|
9967015eba | ||
|
|
3b7e4c9266 | ||
|
|
1dd3fb9341 | ||
|
|
120f9e468d | ||
|
|
88f5c58b8e | ||
|
|
6bd06f61cf | ||
|
|
6a83756fb4 | ||
|
|
515b724047 | ||
|
|
77fef5de57 | ||
|
|
9d09d27fba | ||
|
|
0e767e2f84 | ||
|
|
7801ed0d60 | ||
|
|
6abec9401b | ||
|
|
1cdb9e1ad5 | ||
|
|
7afeb41469 | ||
|
|
bae11fe1f1 | ||
|
|
0cacaee213 | ||
|
|
dcbd8d2a55 | ||
|
|
892e81c185 | ||
|
|
f423ddc53a | ||
|
|
b2bb7ea28e | ||
|
|
38b3815a33 | ||
|
|
92975a5c95 | ||
|
|
a5b61bfaf6 | ||
|
|
85b56300b3 | ||
|
|
8fa9e23c6e | ||
|
|
a7fc7902f0 | ||
|
|
879f5ab52f | ||
|
|
c6458c6ec1 | ||
|
|
47c433e7c1 | ||
|
|
dc41e5062d | ||
|
|
317942b7e1 | ||
|
|
65d768bf02 | ||
|
|
0767bbfecf | ||
|
|
78d6699c68 | ||
|
|
a807d53ff8 | ||
|
|
fa45cf08ba | ||
|
|
c3da3e23af | ||
|
|
5cf5b4edb7 | ||
|
|
0c9c88fc0b | ||
|
|
725bba0963 | ||
|
|
76981635dc | ||
|
|
b56316932f | ||
|
|
8dba2e240e | ||
|
|
4016e81f9a | ||
|
|
5ee37eb0cb | ||
|
|
4650963cc7 | ||
|
|
5acc1ea718 | ||
|
|
505f5b73c5 | ||
|
|
d491b0b347 | ||
|
|
52d6c59f3f | ||
|
|
4afb605736 | ||
|
|
fcc1c2a648 | ||
|
|
4ded8988c3 | ||
|
|
988c2b8af7 | ||
|
|
58ef8f753f | ||
|
|
3e9f1a392a | ||
|
|
2563722f16 | ||
|
|
fb089dd3de | ||
|
|
983612f460 | ||
|
|
d42bd612d0 | ||
|
|
41f6a03751 | ||
|
|
f1e25c9a20 | ||
|
|
15794ebfc8 | ||
|
|
68928f5cb1 | ||
|
|
a514dda1ff | ||
|
|
2bccb698e6 | ||
|
|
076452c612 | ||
|
|
b005b7bcfe | ||
|
|
a2eb42ebb9 | ||
|
|
33ff586af4 | ||
|
|
3803537739 | ||
|
|
6151da821f | ||
|
|
8f7f5e0e32 | ||
|
|
fa140a3047 | ||
|
|
419b6f02a5 | ||
|
|
58818bb91c | ||
|
|
b6ae225342 | ||
|
|
8411889db7 | ||
|
|
313bbe8b49 | ||
|
|
691c643745 | ||
|
|
9e8e929bcc | ||
|
|
2238565a94 | ||
|
|
39e9704436 | ||
|
|
fa43753614 | ||
|
|
02be39b6ed | ||
|
|
375e1a2100 | ||
|
|
e893dc3b24 | ||
|
|
c1ea77434e | ||
|
|
0e1e544fee | ||
|
|
a13cd2f7ba | ||
|
|
befdc64772 | ||
|
|
06f3055913 | ||
|
|
ca2c5b8dfc | ||
|
|
c395a949cc | ||
|
|
4473e9c5b2 | ||
|
|
75a63c4828 | ||
|
|
aea403a874 | ||
|
|
ab8fed14bb | ||
|
|
6f915a5733 | ||
|
|
f970ec867e | ||
|
|
ef0d490890 | ||
|
|
865089d46d | ||
|
|
cd25eadd1c | ||
|
|
d500fa5eeb | ||
|
|
4c681d813d | ||
|
|
ddfab4a341 | ||
|
|
434aa97a86 | ||
|
|
efde4b1142 | ||
|
|
6022bdd3cd | ||
|
|
99baf64b11 | ||
|
|
61b04ba5cf | ||
|
|
2a60360f4a | ||
|
|
8a7cb8bc6f | ||
|
|
1be80b24c2 | ||
|
|
061c653bce | ||
|
|
72a98b0473 | ||
|
|
88e199ef9c | ||
|
|
879497d25a | ||
|
|
3f1d8cf75d | ||
|
|
32721cf7ed | ||
|
|
103409770d | ||
|
|
094ccf4186 | ||
|
|
247808563a | ||
|
|
5927ced485 | ||
|
|
6fb788b259 | ||
|
|
5e92d06f21 | ||
|
|
7082718c14 | ||
|
|
7e2cd6acd3 | ||
|
|
904d0de6aa | ||
|
|
868247e6d4 | ||
|
|
c4e2332b83 | ||
|
|
139d20560f | ||
|
|
66a14cf389 | ||
|
|
9b30c7df6e | ||
|
|
5334d79d0d | ||
|
|
64984bc1b3 | ||
|
|
8ef59f5bff | ||
|
|
9d6ab6b7b3 | ||
|
|
d62b0d3f8d | ||
|
|
918a04c502 | ||
|
|
60f1809ed8 | ||
|
|
f848e73251 | ||
|
|
c65fbb0b60 | ||
|
|
95f114d817 | ||
|
|
05eac1a8ca | ||
|
|
ea42f0f1e3 | ||
|
|
625dc357cc | ||
|
|
e94e11c456 | ||
|
|
a9b5713629 | ||
|
|
dbaa13bfb0 | ||
|
|
5d0d050149 | ||
|
|
c327e94726 | ||
|
|
774780d520 | ||
|
|
5e1167743f | ||
|
|
4376b826c4 | ||
|
|
0fef751ab5 | ||
|
|
206921baf0 | ||
|
|
0d2d3353a9 | ||
|
|
b47687535a | ||
|
|
e092a29b13 | ||
|
|
170839362e | ||
|
|
b95a659396 | ||
|
|
2b66786728 | ||
|
|
b7bfeaf215 | ||
|
|
cf37800c2b | ||
|
|
5cc642098d | ||
|
|
7c01ad88b2 | ||
|
|
e866bb3be5 | ||
|
|
63021bd313 | ||
|
|
cbcb7484a7 | ||
|
|
1c0b407f3f | ||
|
|
280c773441 | ||
|
|
efca460e9d | ||
|
|
8f3b832069 | ||
|
|
9b3d1fa1fd | ||
|
|
9a38aff03d | ||
|
|
06bbe2e400 | ||
|
|
77900f89e3 | ||
|
|
bc39561606 | ||
|
|
76535c6304 | ||
|
|
790ba3d20e | ||
|
|
89779ec13b | ||
|
|
1b6b219e02 | ||
|
|
5cd845e55d | ||
|
|
3a091ac287 | ||
|
|
e385331f6c | ||
|
|
4067b6c182 | ||
|
|
3063236634 | ||
|
|
a17f05ef21 | ||
|
|
a4d42573ef | ||
|
|
bf7a429dac | ||
|
|
67b3e51454 | ||
|
|
5723ff1d20 | ||
|
|
82d873a70f | ||
|
|
7e4f564f45 | ||
|
|
3c8f7aeecf | ||
|
|
3f7b38713f | ||
|
|
0b33edcb4c | ||
|
|
4130a8c5c9 | ||
|
|
06c7968070 | ||
|
|
a98a30cc85 | ||
|
|
a924d648d6 | ||
|
|
0750547236 | ||
|
|
1aa4401e6c | ||
|
|
5e841bf7f8 | ||
|
|
5d524e5110 | ||
|
|
1e96c3211f | ||
|
|
d2649c29c3 | ||
|
|
ff89432d86 | ||
|
|
fb77ab44ee | ||
|
|
e43840da31 | ||
|
|
3fa5ed725e | ||
|
|
841f5ab67f | ||
|
|
4038a837cf | ||
|
|
5b2d7db102 | ||
|
|
c8dd391fd3 | ||
|
|
bde2cd4586 | ||
|
|
780115adb6 | ||
|
|
8eec866b31 | ||
|
|
239657bc3c | ||
|
|
db2bdb8eff | ||
|
|
87d68a3e5c | ||
|
|
09c62fb095 | ||
|
|
b16c00f480 | ||
|
|
9461783f5a | ||
|
|
6420bfbe87 | ||
|
|
5f8cc6c4fb | ||
|
|
f1bc3c9f37 | ||
|
|
0b300e7e12 | ||
|
|
1b067dd95e | ||
|
|
20a39895f9 | ||
|
|
0daba99f08 | ||
|
|
efdffb365a | ||
|
|
ee4dbf99b3 | ||
|
|
e9d7523a1f | ||
|
|
981a4cb8b3 | ||
|
|
2a0a04ac65 | ||
|
|
db0ade203a | ||
|
|
f0becb750a | ||
|
|
8fc9afbad9 | ||
|
|
2b8012b5d4 | ||
|
|
81701df1c7 | ||
|
|
df9a52a8f2 | ||
|
|
d082d3deba | ||
|
|
19c853f07b | ||
|
|
c63249b778 | ||
|
|
8cf41a4043 | ||
|
|
0e726af2de | ||
|
|
f304c2eb02 | ||
|
|
2bc92c9095 | ||
|
|
33b9a012d4 | ||
|
|
860213c427 | ||
|
|
272972429b | ||
|
|
3e2b2f2be1 | ||
|
|
db0e362b7d | ||
|
|
5ffc2046d4 | ||
|
|
3bd6075a9b | ||
|
|
11a61a06a6 | ||
|
|
3f96b37b51 | ||
|
|
4f89a0696b | ||
|
|
16862df71e | ||
|
|
3f4d0c0c76 | ||
|
|
73c82a91c0 | ||
|
|
f194259ab3 | ||
|
|
3b9d083f5e | ||
|
|
9062f61508 | ||
|
|
75441cdf56 | ||
|
|
b2fa92a8c3 | ||
|
|
d533c7acfe | ||
|
|
e6da63ff09 | ||
|
|
461c2c4600 | ||
|
|
0ab41f74dc | ||
|
|
1ab7127ed3 | ||
|
|
6b2bd06393 | ||
|
|
04a559e471 | ||
|
|
6b7354b14f | ||
|
|
2c719ae1ae | ||
|
|
488711ee8f | ||
|
|
78f04a2ffc | ||
|
|
58530563ce | ||
|
|
259008df50 | ||
|
|
20f8a5a501 | ||
|
|
9afd81b372 | ||
|
|
2d2431364d | ||
|
|
16440a4170 | ||
|
|
1f08ea9eea | ||
|
|
2850988bfe | ||
|
|
2d6c0bd02b | ||
|
|
9280159531 | ||
|
|
8d13b947e2 | ||
|
|
22ef33c6ca | ||
|
|
ee568534f6 | ||
|
|
c204c6ca7f | ||
|
|
87cfd9e154 | ||
|
|
72e5d7ccf8 | ||
|
|
a459661fa2 | ||
|
|
a56de2818d | ||
|
|
9ea41bd2a9 | ||
|
|
14b48e676c | ||
|
|
3397418cab | ||
|
|
7e9fed3d26 | ||
|
|
7dc67d6b6f | ||
|
|
4fb5744cb3 | ||
|
|
5617d1c7ae | ||
|
|
666074ed49 | ||
|
|
4c0de78fb4 | ||
|
|
dcb0cf6a6d | ||
|
|
e7fd9c0aec | ||
|
|
ea002ad45d | ||
|
|
6328e316f4 | ||
|
|
b49e87c468 | ||
|
|
c4b175b2db | ||
|
|
b52aa4daba | ||
|
|
8d76961e77 | ||
|
|
4cf7eab950 | ||
|
|
5f2febf09c | ||
|
|
e276700b6d | ||
|
|
4a64748da8 | ||
|
|
5d8dc76e7a | ||
|
|
4dfc9cef52 | ||
|
|
571c2ce3a4 | ||
|
|
229555d97d | ||
|
|
73da59ffbf | ||
|
|
c7a05a9ba3 | ||
|
|
9e1316b543 | ||
|
|
72924fab66 | ||
|
|
72f091b85e | ||
|
|
fdf5b2d802 | ||
|
|
a34c9479f2 | ||
|
|
162c05628c | ||
|
|
1125c0c4bf | ||
|
|
0e5421a558 | ||
|
|
5ec0636807 | ||
|
|
6641db3e7e | ||
|
|
77c26134d1 | ||
|
|
ef685ecb42 | ||
|
|
df1018e70c | ||
|
|
0658424b94 | ||
|
|
d1b5644c63 | ||
|
|
4936f2fdf2 | ||
|
|
0fcd6c086b | ||
|
|
34c5b2613c | ||
|
|
6b246e7808 | ||
|
|
eda7528f7d | ||
|
|
fa46b30e61 | ||
|
|
2acaeb34db | ||
|
|
f8b0977a7f | ||
|
|
4bab1a9815 | ||
|
|
ce7adfee3a | ||
|
|
a350d735be | ||
|
|
3f86786ee8 | ||
|
|
d6c124aae8 | ||
|
|
2d942fe9b5 | ||
|
|
f18e2ce599 | ||
|
|
08350181f3 | ||
|
|
114c7c2811 | ||
|
|
83057d9e54 | ||
|
|
d73a131616 | ||
|
|
1b5a810520 | ||
|
|
2f685859cf | ||
|
|
cacf6e43b8 | ||
|
|
991ab6a2bd | ||
|
|
188eb9fc41 | ||
|
|
4db3775eba | ||
|
|
57841ea1c0 | ||
|
|
3602ec4560 | ||
|
|
afa865edb1 | ||
|
|
bf5a93dae0 | ||
|
|
4d65d45a59 | ||
|
|
c75ef72e74 | ||
|
|
5d0689abba | ||
|
|
c0e36133a1 | ||
|
|
89ac53083f | ||
|
|
18acf22d2e | ||
|
|
98366f1051 | ||
|
|
122b7f0d82 | ||
|
|
d6a861118f | ||
|
|
37e173a5cc | ||
|
|
18a4484fa8 | ||
|
|
fc04fdadd5 | ||
|
|
96cb5ef9ed | ||
|
|
f665a1c7f5 | ||
|
|
092c467a2e | ||
|
|
1471fce2c3 | ||
|
|
8db361cc88 | ||
|
|
00d7c33af6 | ||
|
|
7d2bdc58ba | ||
|
|
61b9110a5c | ||
|
|
d033573bd6 | ||
|
|
7d6a879966 | ||
|
|
9a526f93f4 | ||
|
|
5888e5442a | ||
|
|
d5885273ac | ||
|
|
db4798754a | ||
|
|
646bc1b12e | ||
|
|
d9f73622a5 | ||
|
|
cd8b8aafc6 | ||
|
|
e299308711 | ||
|
|
eccad8968b | ||
|
|
72e9234b2b | ||
|
|
eb812d2b0a | ||
|
|
ce4288ff66 | ||
|
|
14d5a7ee1a | ||
|
|
0c8830793c | ||
|
|
46229db64c | ||
|
|
7941b0412b | ||
|
|
3db6077adf | ||
|
|
d690c4591c | ||
|
|
98f5b66826 | ||
|
|
43a41973d9 | ||
|
|
00379bfe26 | ||
|
|
7f603cb06a | ||
|
|
34ed6ebc63 | ||
|
|
14e0429758 | ||
|
|
73e1bba541 | ||
|
|
599dd26b53 | ||
|
|
0b60377e19 | ||
|
|
6ed2308f99 | ||
|
|
a2e2fd1b89 | ||
|
|
09556dbf21 | ||
|
|
470ecb007d | ||
|
|
8af10f8c07 | ||
|
|
7a9f5e5685 | ||
|
|
bad4b48573 | ||
|
|
db21ee8bcb | ||
|
|
63f35b19fe | ||
|
|
ef44cb852c | ||
|
|
ae6bb4e757 | ||
|
|
5244cddeb3 | ||
|
|
fff389359a | ||
|
|
49961b2c4b | ||
|
|
c987c13fc7 | ||
|
|
4cccb73812 | ||
|
|
e804dd1aec | ||
|
|
f45a3095cb | ||
|
|
3b4969dcea | ||
|
|
18f6455eb2 | ||
|
|
71dd63d3f0 | ||
|
|
21028d4e25 | ||
|
|
2753ce93a2 | ||
|
|
3b3d151ec3 | ||
|
|
c25af5cfaa | ||
|
|
018b578982 | ||
|
|
0f6bc3a420 | ||
|
|
617790b68f | ||
|
|
762a0fe8a1 | ||
|
|
0d21bfe929 | ||
|
|
abc3150f59 | ||
|
|
49b120ed2b | ||
|
|
37218fea1f | ||
|
|
2ad9448dfa | ||
|
|
5c63d3cdc6 | ||
|
|
bf6cd01d80 | ||
|
|
c06f6c68b4 | ||
|
|
706a710587 | ||
|
|
31391c5289 | ||
|
|
40c4ad1f30 | ||
|
|
41c530e994 | ||
|
|
e98d2d3590 | ||
|
|
61ccb1c47a | ||
|
|
b4b287bda4 | ||
|
|
ca8f4c9fa4 | ||
|
|
18b8a17df1 | ||
|
|
f192c39be8 | ||
|
|
f1b89345e2 | ||
|
|
f6c702fba7 | ||
|
|
f619a5f72d | ||
|
|
f7dd77ee3e | ||
|
|
0f41c54406 | ||
|
|
13d1927840 | ||
|
|
d0124c1a5c | ||
|
|
5ed80d983d | ||
|
|
24d66e33a7 | ||
|
|
786caf7bc1 | ||
|
|
e4b7258f7d | ||
|
|
90258e75e6 | ||
|
|
b48b7c7e72 | ||
|
|
454d520394 | ||
|
|
ee5f83e89f | ||
|
|
fa518c029c | ||
|
|
aa1d0b759e | ||
|
|
abfd109203 | ||
|
|
ebf53f6486 | ||
|
|
2450a4ad1d | ||
|
|
41ea0c2c58 | ||
|
|
acb24e38a6 | ||
|
|
a1fbc0b436 | ||
|
|
e052935e2d | ||
|
|
4d2bd51603 | ||
|
|
b3d3ebe9d0 | ||
|
|
d05c00aac6 | ||
|
|
0c8fcbd724 | ||
|
|
7e67ed02aa | ||
|
|
e101c4abc7 | ||
|
|
6a7a84792e | ||
|
|
2fa907c478 | ||
|
|
cc965ac473 | ||
|
|
8f5311ff04 | ||
|
|
e590d456a1 | ||
|
|
a5be5a719b | ||
|
|
b5ceb264ec | ||
|
|
31c967cc11 | ||
|
|
2818bd5d52 | ||
|
|
939cd7fb62 | ||
|
|
172ced7129 | ||
|
|
11c122cba3 | ||
|
|
456bc57df3 | ||
|
|
091024553b | ||
|
|
ad016df105 | ||
|
|
79d134cea8 | ||
|
|
fb046bed5b | ||
|
|
f6b6185fb2 | ||
|
|
5b7e3e877b | ||
|
|
3c5302336f | ||
|
|
55c9721b51 | ||
|
|
fc788c586d | ||
|
|
e912cf7fb6 | ||
|
|
c111050c8f | ||
|
|
0e8d3b76c1 | ||
|
|
eda56236ce | ||
|
|
b4620504b8 | ||
|
|
b751ae183a | ||
|
|
0b2b8aacef | ||
|
|
e21d66ce85 | ||
|
|
66b72da02a | ||
|
|
e0d6ce6c7f | ||
|
|
97e9d6f8ce | ||
|
|
0fb2f3fcc6 | ||
|
|
32fc89f4e7 | ||
|
|
10fbfadf51 | ||
|
|
e9c457cac2 | ||
|
|
0f191d8a3e | ||
|
|
dcf317e471 | ||
|
|
749261c146 | ||
|
|
238c6bc080 | ||
|
|
476d4d4909 | ||
|
|
651a642e71 | ||
|
|
01a8c65df9 | ||
|
|
d69460bf98 | ||
|
|
f0848283bd | ||
|
|
fdc1df313b | ||
|
|
b425e7d75c | ||
|
|
fb25b76950 | ||
|
|
a18723991a | ||
|
|
74aa134971 | ||
|
|
24b02150a7 | ||
|
|
f717c71c66 | ||
|
|
814de87768 | ||
|
|
c392563ef8 | ||
|
|
1fbd603374 | ||
|
|
749f61aba6 | ||
|
|
59f347c135 | ||
|
|
904f449d37 | ||
|
|
9b69a8dc91 | ||
|
|
50ea7db66c | ||
|
|
e4c5529412 | ||
|
|
b4fe9a784f | ||
|
|
6b2fe125d4 | ||
|
|
e7f960bf46 | ||
|
|
02ed521f21 | ||
|
|
39902cb1c6 | ||
|
|
700a8cb54a | ||
|
|
3eacc06383 | ||
|
|
f8dccfcd15 | ||
|
|
725f17bcd2 | ||
|
|
ff82690d3c | ||
|
|
51f7210195 | ||
|
|
25d2ddd08a | ||
|
|
4af12aee15 | ||
|
|
ed341fbc51 | ||
|
|
f0874b2d02 | ||
|
|
baacd3ee39 | ||
|
|
9cd23c3666 | ||
|
|
0e17e2a6cf | ||
|
|
2dea0aa57b | ||
|
|
ba1c8c15c4 | ||
|
|
f16915be11 | ||
|
|
858d437f3f | ||
|
|
de30ac302a | ||
|
|
dd8597307c | ||
|
|
608403c113 | ||
|
|
40eff8e30e | ||
|
|
0bba36cbc3 | ||
|
|
a5788117de | ||
|
|
3f1075d0b2 | ||
|
|
dea2688b49 | ||
|
|
4f1daeb18c | ||
|
|
927e6fa909 | ||
|
|
bd7cdb3942 | ||
|
|
9dfd967a32 | ||
|
|
6eee762d3a | ||
|
|
a8c5773f81 | ||
|
|
fbb52dc93f | ||
|
|
c9e936da21 | ||
|
|
c825e67f69 | ||
|
|
bcb7b9443b | ||
|
|
896d166dcf | ||
|
|
db6840639a | ||
|
|
6af080ab6e | ||
|
|
f28c47323f | ||
|
|
73cd9a11f8 | ||
|
|
19fed77218 | ||
|
|
b25818dbc3 | ||
|
|
f2a8b070f2 | ||
|
|
dfacc3039a | ||
|
|
2b8ec1f8cf | ||
|
|
265a53ace3 | ||
|
|
6d22e62642 | ||
|
|
17c178c8bf | ||
|
|
301a734905 | ||
|
|
1c3febc49d | ||
|
|
a6dd927860 | ||
|
|
3c92de8e24 | ||
|
|
e51a662da9 | ||
|
|
a7aead728c | ||
|
|
b2bf4ecc02 | ||
|
|
06795a014c | ||
|
|
8f2794a141 | ||
|
|
396b6b80cd | ||
|
|
e467beb1c7 | ||
|
|
c70942ff90 | ||
|
|
f26d02919e | ||
|
|
e65ba08ec6 | ||
|
|
09b75211c3 | ||
|
|
6c019ba112 | ||
|
|
ac79ce91da | ||
|
|
20ff465b94 | ||
|
|
7d3a5cc699 | ||
|
|
93dc38e829 | ||
|
|
17ddc95eb9 | ||
|
|
1b22e61384 | ||
|
|
e6b673832f | ||
|
|
57a9fff82b | ||
|
|
1896e982b9 | ||
|
|
0e7e0a0560 | ||
|
|
115db944c8 | ||
|
|
650ac94e68 | ||
|
|
155439e228 | ||
|
|
25877cf016 | ||
|
|
15996ea959 | ||
|
|
1a0421a45a | ||
|
|
0210a97b48 | ||
|
|
a4932b163b | ||
|
|
0b41d288db | ||
|
|
21f3ee88b9 | ||
|
|
7727f533ee | ||
|
|
41c71fde56 | ||
|
|
c27e446ec6 | ||
|
|
3d1561de75 | ||
|
|
3df6eceb20 | ||
|
|
81aa27e802 | ||
|
|
bd4710ebdc | ||
|
|
8a1ae1ef83 | ||
|
|
6afe25a4af | ||
|
|
cc40f46325 | ||
|
|
7c3bfa94f3 | ||
|
|
25f1539816 | ||
|
|
474ab22792 | ||
|
|
257f7a0287 | ||
|
|
c39ce61b2c | ||
|
|
980b99783b | ||
|
|
ececc3cedd | ||
|
|
0891c49620 | ||
|
|
c26ef012c1 | ||
|
|
31061c0eb0 | ||
|
|
994f7c2443 | ||
|
|
7029441f89 | ||
|
|
35f1084cc2 | ||
|
|
44473a364f | ||
|
|
fb4d6b7be3 | ||
|
|
b76fa69396 | ||
|
|
8f711d359b | ||
|
|
9fd6f9c58b | ||
|
|
b70cbfa0a8 | ||
|
|
d0675368f4 | ||
|
|
195acd3246 | ||
|
|
5284975aa2 | ||
|
|
13018192f3 | ||
|
|
07f50b6619 | ||
|
|
36d0f08efb | ||
|
|
fd75def6c3 | ||
|
|
61da0c8af3 | ||
|
|
91a169fa3e | ||
|
|
e1c4702398 | ||
|
|
5407d35779 | ||
|
|
26cc7846c6 | ||
|
|
36560735f2 | ||
|
|
41972f64ad | ||
|
|
83866a0d79 | ||
|
|
aa0739bfbe | ||
|
|
d797f47bb8 | ||
|
|
09f3ce0109 | ||
|
|
2ba31a3937 | ||
|
|
31e2fcb322 | ||
|
|
e69cd1129c | ||
|
|
8a59b66a0f | ||
|
|
dcf7658972 | ||
|
|
e79d64d707 | ||
|
|
503a4ed822 | ||
|
|
f0a44678ed | ||
|
|
4b1ebf44c9 | ||
|
|
eb229440d4 | ||
|
|
88b412a2e9 | ||
|
|
14d3a7586d | ||
|
|
6ec65f9fe8 | ||
|
|
0ec6558282 | ||
|
|
52d31f9cca | ||
|
|
07016f2cc5 | ||
|
|
55a73995c8 | ||
|
|
a40ebc7bbc | ||
|
|
e4dd47aed0 | ||
|
|
eb5a7338ab | ||
|
|
b77d50d4ed | ||
|
|
b520a9bfb0 | ||
|
|
242ca19586 | ||
|
|
6a6665bdf9 | ||
|
|
8e8c00af40 | ||
|
|
ae3cf7eb80 | ||
|
|
0a2b6ee90a | ||
|
|
d065b5b90a | ||
|
|
0528220b5e | ||
|
|
f0850522a7 | ||
|
|
cc2fff8bef | ||
|
|
2d13011866 | ||
|
|
146f17bf74 | ||
|
|
b406250ae2 | ||
|
|
ed9237b69d | ||
|
|
393e00866b | ||
|
|
411b09629a | ||
|
|
79996f6838 | ||
|
|
6bb7f80ea2 | ||
|
|
3b17c01c6d | ||
|
|
91bccfd057 | ||
|
|
8cca09e0ac | ||
|
|
1479516689 | ||
|
|
831e59a626 | ||
|
|
6e012383ab | ||
|
|
e988192343 | ||
|
|
ef3abd01b1 | ||
|
|
e664338845 | ||
|
|
63a1237683 | ||
|
|
6d59da4c86 | ||
|
|
5bc67104ea | ||
|
|
72ec78e011 | ||
|
|
de8b7d6b43 | ||
|
|
048409bb1e | ||
|
|
efff4cd96d | ||
|
|
78d7947a5c | ||
|
|
c132a37f03 | ||
|
|
4678a7507b | ||
|
|
e8e240c89b | ||
|
|
1857ac44ca | ||
|
|
8c07932071 | ||
|
|
74d74d95a1 | ||
|
|
b4add20d10 | ||
|
|
e9619c1242 | ||
|
|
49d33a2765 | ||
|
|
ecc58f6c11 | ||
|
|
e5f43e5fe9 | ||
|
|
499c47c7a1 | ||
|
|
2cf30e1127 | ||
|
|
ebfc4a349f | ||
|
|
150c1dc273 | ||
|
|
30abbe9be7 | ||
|
|
40e4ef0e05 | ||
|
|
2ceb1b701a | ||
|
|
2ad093a9a8 | ||
|
|
09a94d0df5 | ||
|
|
f7f4ca7728 | ||
|
|
83a90000c6 | ||
|
|
bbb16bb3c2 | ||
|
|
4d9ee4494f | ||
|
|
eeeabca8c2 | ||
|
|
bb0f3dad1a | ||
|
|
9c0c263fce | ||
|
|
aed2d34149 | ||
|
|
3504408e70 | ||
|
|
41b3e06f73 | ||
|
|
c41f0a7cc4 | ||
|
|
19d9af821d | ||
|
|
5d262d7d48 | ||
|
|
231ac1e012 | ||
|
|
cbb714c235 | ||
|
|
c93e935b9c | ||
|
|
c6d696a91b | ||
|
|
a9b8d34b5a | ||
|
|
25e2bdb0b7 | ||
|
|
bbb0c26498 | ||
|
|
386c456415 | ||
|
|
51a45bb59e | ||
|
|
0759b4f220 | ||
|
|
9f92bf95b4 | ||
|
|
e638f8b487 | ||
|
|
caaed252f9 | ||
|
|
5982932457 | ||
|
|
9a31243b1c | ||
|
|
88e526f5e5 | ||
|
|
730ac15349 | ||
|
|
0ac9243389 | ||
|
|
c39f8a9cb8 | ||
|
|
9dd1e9a29f | ||
|
|
3f5104d6d8 | ||
|
|
4b00f454b7 | ||
|
|
33f27f018f | ||
|
|
a7d9b4584b | ||
|
|
4e699621a0 | ||
|
|
2dd51c38a7 | ||
|
|
37e6f8656a | ||
|
|
1fd9981273 | ||
|
|
3a2a7dac40 | ||
|
|
ef69ab2799 | ||
|
|
f49e313e2f | ||
|
|
9fe6d41320 | ||
|
|
c0ccc267c8 | ||
|
|
1aef0451b7 | ||
|
|
e8763b87cc | ||
|
|
03527b4ace | ||
|
|
526d75af4f | ||
|
|
7e515b52e1 | ||
|
|
3679d28ffe | ||
|
|
ced2452d6e | ||
|
|
b301a89ce1 | ||
|
|
0749cdd8f3 | ||
|
|
da251b28a4 | ||
|
|
5bccbfbd84 | ||
|
|
1f0ef10d35 | ||
|
|
ab6689c7c8 | ||
|
|
ddc3ea5e72 | ||
|
|
669f1ffe2c | ||
|
|
03179eea94 | ||
|
|
e763c6d13e | ||
|
|
14387eb8d6 | ||
|
|
619370c670 | ||
|
|
ca13ddec26 | ||
|
|
0ab809447a | ||
|
|
f94bbec672 | ||
|
|
8cc6e77169 | ||
|
|
5f63dc93ae | ||
|
|
3722f11a65 | ||
|
|
087043811f | ||
|
|
3ca86ba91d | ||
|
|
93265a8dca | ||
|
|
160c23c36c | ||
|
|
891911e56b | ||
|
|
94fa191fb8 | ||
|
|
9a2b29c2ae | ||
|
|
7a51368955 | ||
|
|
7fa0d0f331 | ||
|
|
4eba36f7a0 | ||
|
|
6b3ff009da | ||
|
|
fcac10a6ce | ||
|
|
1613b71942 | ||
|
|
83beb53860 | ||
|
|
8a4c50779a | ||
|
|
ca5b00a373 | ||
|
|
34a1fe9e8e | ||
|
|
34c18e75f5 | ||
|
|
6fbac12314 | ||
|
|
c4107fc370 | ||
|
|
ca3ea20283 | ||
|
|
8f72c5f42c | ||
|
|
14d85e83e6 | ||
|
|
cd139dfc1c | ||
|
|
bc7d90f1f4 | ||
|
|
3a21690309 | ||
|
|
487c81d50b | ||
|
|
088e87bccf | ||
|
|
6cc5fd8890 | ||
|
|
4e3d71e0e3 | ||
|
|
9b107b9940 | ||
|
|
43eec5aac0 | ||
|
|
430fdb6f1c | ||
|
|
1fa26cdc44 | ||
|
|
fa9d6df406 | ||
|
|
eb7313fe6b | ||
|
|
71b3654942 | ||
|
|
3f99f7edff | ||
|
|
b76f38e0bc | ||
|
|
1477370376 | ||
|
|
04fc6ed26a | ||
|
|
7305216485 | ||
|
|
3147df20da | ||
|
|
d086f63861 | ||
|
|
40bb3e880e | ||
|
|
251e98cfa3 | ||
|
|
eb6d6be3b9 | ||
|
|
b8ca324aaf | ||
|
|
7d45d23767 | ||
|
|
9224696e33 | ||
|
|
50f4e0e5b7 | ||
|
|
fcdb31e848 | ||
|
|
70ac33928f | ||
|
|
59f1c111aa | ||
|
|
1cf82bdbbd | ||
|
|
ef803a157a | ||
|
|
51ceffd58f | ||
|
|
f05c292170 | ||
|
|
b819c2f723 | ||
|
|
fe610fdaca | ||
|
|
2fc4ed419e | ||
|
|
c84fbdfb9c | ||
|
|
7231b5d245 | ||
|
|
3d16954c79 | ||
|
|
c5746089f5 | ||
|
|
dc29c6718c | ||
|
|
d4b1d97f5c | ||
|
|
f71644c8bc | ||
|
|
80c76c22ca | ||
|
|
4853a8b12b | ||
|
|
fd00369859 | ||
|
|
a8a7edb93e | ||
|
|
5f90d21234 | ||
|
|
c59c1fc007 | ||
|
|
3007e02fe5 | ||
|
|
39c4bd8883 | ||
|
|
cd3e9dd024 | ||
|
|
8edde732b6 | ||
|
|
3df1df8b5a | ||
|
|
1c233dd4f4 | ||
|
|
385f53372c | ||
|
|
f79d30ab54 | ||
|
|
a8ee0da2b8 | ||
|
|
0c84386209 | ||
|
|
f52a00937f | ||
|
|
4b33559212 | ||
|
|
fd5de99674 | ||
|
|
582011d247 | ||
|
|
c316d05549 | ||
|
|
77a19a62df | ||
|
|
31378ac756 | ||
|
|
10385b1414 | ||
|
|
d8c7b3df0b | ||
|
|
99e0c1c90e | ||
|
|
236215cc4c | ||
|
|
552636d882 | ||
|
|
9d73dbc45a | ||
|
|
859bf2a28d | ||
|
|
365ebf53a5 | ||
|
|
fe3e3cfacc | ||
|
|
efa240440b | ||
|
|
e2f4dd124a | ||
|
|
90337745bf | ||
|
|
f885965c2b | ||
|
|
a07d789e66 | ||
|
|
6a1cb15114 | ||
|
|
b1a7a6a148 | ||
|
|
39a6fbea74 | ||
|
|
573c1514bf | ||
|
|
348a114981 | ||
|
|
a84e657577 | ||
|
|
03dd25cff4 | ||
|
|
286b1cf9b6 | ||
|
|
bace7d41af | ||
|
|
738b083a7f | ||
|
|
1ac92254ad | ||
|
|
e404bac7c3 | ||
|
|
0a839ff960 | ||
|
|
a8b62e5cdc | ||
|
|
730d12212f | ||
|
|
29f8e148da | ||
|
|
71c441d871 | ||
|
|
00750500a1 | ||
|
|
77228ccb65 | ||
|
|
f0fba0644d | ||
|
|
2292e6506d | ||
|
|
4775c9d830 | ||
|
|
cae00b032b | ||
|
|
3ae9fe5405 | ||
|
|
12fa5748d4 | ||
|
|
ab9c02dfad | ||
|
|
4de5b2d091 | ||
|
|
ed6521acad | ||
|
|
9ab6955125 | ||
|
|
46622d9ab2 | ||
|
|
607d9b3030 | ||
|
|
4eb2fb01d9 | ||
|
|
3caafc6e21 | ||
|
|
8a3349c6a5 | ||
|
|
b46910ec90 | ||
|
|
1cd285aec4 | ||
|
|
e51193f988 | ||
|
|
0783d47ffe | ||
|
|
6f5969f520 | ||
|
|
0c8fa8f49e | ||
|
|
80af255f25 | ||
|
|
cdc617c382 | ||
|
|
f1fb504874 | ||
|
|
e04690d96d | ||
|
|
5dce2441fa | ||
|
|
5d8e314983 | ||
|
|
9e37c0fab1 | ||
|
|
cc49c256c1 | ||
|
|
dc8e330b22 | ||
|
|
358194468e | ||
|
|
1d3d3b13ed | ||
|
|
f15bb50cfe | ||
|
|
fcadb5ead8 | ||
|
|
6d874f4b7a | ||
|
|
a0ed88580f | ||
|
|
9537c388ab | ||
|
|
40d7e6da37 | ||
|
|
fa60f2e5f8 | ||
|
|
e4b4430e69 | ||
|
|
bcf9185bd3 | ||
|
|
337b373628 | ||
|
|
59d58f8866 | ||
|
|
d0f826485f | ||
|
|
d5cd90eb34 | ||
|
|
8d1d09e698 | ||
|
|
fb4c11bd38 | ||
|
|
bfcc538ed1 | ||
|
|
912c19f6cf | ||
|
|
fda520ad44 | ||
|
|
1f7d6871cf | ||
|
|
5b1c9c64de | ||
|
|
f802c4d596 | ||
|
|
192d379a3e | ||
|
|
9f5c9b17a5 | ||
|
|
fa25a56126 | ||
|
|
75cd9d382d | ||
|
|
22a3d3f6cd | ||
|
|
fd4f15ab66 | ||
|
|
1f1dfcb54f | ||
|
|
3d451e47e5 | ||
|
|
fc10fa62e8 | ||
|
|
875c601f4e | ||
|
|
91a9477bba | ||
|
|
c9399f61d0 | ||
|
|
fc311a7b7e | ||
|
|
f803c5298b | ||
|
|
e67d576d79 | ||
|
|
7c6041d686 | ||
|
|
1411262793 | ||
|
|
9fd7e2b11a | ||
|
|
3ba6e0478d | ||
|
|
b68a00073f | ||
|
|
89428297c9 | ||
|
|
a7945e30e3 | ||
|
|
ac9df4e082 | ||
|
|
a6937db5fd | ||
|
|
05b8dbc02f | ||
|
|
1498fadf27 | ||
|
|
f0e82caebb | ||
|
|
2ef8823c2d | ||
|
|
dc67293052 | ||
|
|
544d842a4a | ||
|
|
774220ab1f | ||
|
|
105d5bf3f7 | ||
|
|
026cc378fe | ||
|
|
8170d4913b | ||
|
|
34708dd59f | ||
|
|
7595e7501f | ||
|
|
f6950a2ca5 | ||
|
|
aff0cfb794 | ||
|
|
d3e9646fb6 | ||
|
|
6e3df21f8c | ||
|
|
1ce832b846 | ||
|
|
6a6c8fa5d8 | ||
|
|
c186798e78 | ||
|
|
8c4607fee9 | ||
|
|
40c8e6d146 | ||
|
|
fb12a32d4f | ||
|
|
437e83b2ae | ||
|
|
25bc66ae80 | ||
|
|
ad5e74cb27 | ||
|
|
6822ed380d | ||
|
|
b28905bbae | ||
|
|
36a2996cda | ||
|
|
b98ccd9dab | ||
|
|
060f0d575e | ||
|
|
99c97a703f | ||
|
|
806448624d | ||
|
|
df777104af | ||
|
|
d88d6d6a61 | ||
|
|
6078d8d276 | ||
|
|
c4d6bb35a3 | ||
|
|
a25b101c3a | ||
|
|
e0db73543e | ||
|
|
4812b8da55 | ||
|
|
8579fb4cc1 | ||
|
|
b8b95f9d79 | ||
|
|
70506ad8f6 | ||
|
|
ec00568008 | ||
|
|
2044dba700 | ||
|
|
241d8326f7 | ||
|
|
4d83af7c14 | ||
|
|
265795f4a9 | ||
|
|
5c3f0d1e5f | ||
|
|
047fc1f3f1 | ||
|
|
8fce31b983 | ||
|
|
8f87c4774a | ||
|
|
fef866bf0b | ||
|
|
428cc315e4 | ||
|
|
385d6bace8 | ||
|
|
78720b33b7 | ||
|
|
ce4fa8ee61 | ||
|
|
13cbada539 | ||
|
|
a5f6be16e1 | ||
|
|
5abf988810 | ||
|
|
b494fc10af | ||
|
|
d99ce0d98e | ||
|
|
09500557c8 | ||
|
|
7524691b79 | ||
|
|
f1de8db4f3 | ||
|
|
08f5248e7a | ||
|
|
1abe4fb4d6 | ||
|
|
b1435434e6 | ||
|
|
4467e97f3c | ||
|
|
af8e01cd8c | ||
|
|
656a0c7327 | ||
|
|
3efa388b5a | ||
|
|
16f33feda0 | ||
|
|
3ea5e9c537 | ||
|
|
4d30bed3cc | ||
|
|
0e56efc428 | ||
|
|
077692987b | ||
|
|
b071612038 | ||
|
|
0414df0de0 | ||
|
|
a2cae51f48 | ||
|
|
9f652802ae | ||
|
|
4e4cfe3334 | ||
|
|
4e2d0fa464 | ||
|
|
6cb892a811 | ||
|
|
5f6158243e | ||
|
|
1664b0d4fc | ||
|
|
e98ffc0050 | ||
|
|
52013aff3f | ||
|
|
44af78b7e3 | ||
|
|
2bf9e9683b | ||
|
|
f5f46349b2 | ||
|
|
851fbae900 | ||
|
|
91452b5114 | ||
|
|
5b37bd059c | ||
|
|
9079a2a78b | ||
|
|
8fe00e2152 | ||
|
|
ac531affb5 | ||
|
|
3ef35a9d53 | ||
|
|
7aaf140ccb | ||
|
|
ad2647c4ba | ||
|
|
a82e78f8bf | ||
|
|
365a2bf59f | ||
|
|
e5e83287ab | ||
|
|
8e860d4f01 | ||
|
|
0749202d5d | ||
|
|
160676acc1 | ||
|
|
8591c44ef2 | ||
|
|
2b2ff814e3 | ||
|
|
87b72a571d | ||
|
|
00e9e4bc53 | ||
|
|
b93a6f689b | ||
|
|
b2f69d1433 | ||
|
|
2eea07c85e | ||
|
|
b6f9fb58ad | ||
|
|
2fc0cbacf5 | ||
|
|
0fc0cc8e87 | ||
|
|
f3efca7464 | ||
|
|
c57d6c73cc | ||
|
|
b64a2f8195 | ||
|
|
2f4d4e715b | ||
|
|
44733adf4d | ||
|
|
26bc2d2af4 | ||
|
|
68f19b1719 | ||
|
|
3eb0353fa9 | ||
|
|
af2783c18a | ||
|
|
8b15ea5dc8 | ||
|
|
3d26f320bf | ||
|
|
7028621bc5 | ||
|
|
385771cba0 | ||
|
|
79b0a989be | ||
|
|
cccb8e570a | ||
|
|
b7922d171d | ||
|
|
e1cdd79b45 | ||
|
|
fec6237907 | ||
|
|
3df8f65db6 | ||
|
|
c703105c5e | ||
|
|
51f63f7b71 | ||
|
|
45518dc3d2 | ||
|
|
c4a547f407 | ||
|
|
b28773b3a0 | ||
|
|
50006e423c | ||
|
|
8d5b4ac242 | ||
|
|
52a54fbe31 | ||
|
|
382e89abb7 | ||
|
|
917e73ec4d | ||
|
|
edc9f3fe15 | ||
|
|
8410b9310f | ||
|
|
fb2a7d59d4 | ||
|
|
ee2025664f | ||
|
|
b8c2c67707 | ||
|
|
28923ea9c7 | ||
|
|
21a76595ec | ||
|
|
e8a533bbbe | ||
|
|
9ad8177b7a | ||
|
|
709c7b88f5 | ||
|
|
8ba9ced833 | ||
|
|
f62e75f343 | ||
|
|
1a95797997 | ||
|
|
c660edd997 | ||
|
|
9498cf94f3 | ||
|
|
099f9dfe00 | ||
|
|
1cbe0bbfc0 | ||
|
|
49cd272557 | ||
|
|
1c27934d77 | ||
|
|
c85be45846 | ||
|
|
7a1f77e548 | ||
|
|
3999d679e3 | ||
|
|
dc2fb65312 | ||
|
|
6aef3dfb96 | ||
|
|
49eb565e2a | ||
|
|
2ce90077b6 | ||
|
|
fd334bcafe | ||
|
|
6d16d75ef9 | ||
|
|
d5b3b90202 | ||
|
|
37140551d2 | ||
|
|
08f74caef0 | ||
|
|
f7fce001c4 | ||
|
|
4915aa0c11 | ||
|
|
5616d3ee0d | ||
|
|
e823d6e1b3 | ||
|
|
e8eb7077ed | ||
|
|
5fb15b79a4 | ||
|
|
2a774c3d3e | ||
|
|
b6424e0e56 | ||
|
|
386220ee94 | ||
|
|
4c46f0bdbd | ||
|
|
30cf54c49c | ||
|
|
fb512b26ea | ||
|
|
2ae25540d1 | ||
|
|
6229d92333 | ||
|
|
6e348892f2 | ||
|
|
665d0a1b24 | ||
|
|
42f4f4cb6f | ||
|
|
2d6cc43a9e | ||
|
|
bf0b07f3d7 | ||
|
|
88ebb6d648 | ||
|
|
3015e59a29 | ||
|
|
7a3609e611 | ||
|
|
ad8d75c3e8 | ||
|
|
f36ae6c4b9 | ||
|
|
8678cdcadb | ||
|
|
1ac6da1f3e | ||
|
|
e5b9b1caef | ||
|
|
8f80cf2d22 | ||
|
|
5f3d52c76a | ||
|
|
816c68464b | ||
|
|
a500f9ec63 | ||
|
|
8e976922e9 | ||
|
|
eb5483c6b2 | ||
|
|
3231dbe29a | ||
|
|
ae082eb309 | ||
|
|
23f9894154 | ||
|
|
f6159a48d5 | ||
|
|
ada2c1fe59 | ||
|
|
5b59c3296f | ||
|
|
c9373eee15 | ||
|
|
e858bba944 | ||
|
|
9a2dead76c | ||
|
|
53f93a7bb9 | ||
|
|
7924f28ccb | ||
|
|
ff2de43752 | ||
|
|
275cf1263b | ||
|
|
f49895917a | ||
|
|
f7c73f7eba | ||
|
|
6ad1c8e73b | ||
|
|
21f70493bb | ||
|
|
5cb6076a1c | ||
|
|
f660b05a20 | ||
|
|
8f7179ad0c | ||
|
|
3df9a53a24 | ||
|
|
fde1a37563 | ||
|
|
c475700cdd | ||
|
|
9bb68aac66 | ||
|
|
279c4538ca | ||
|
|
42eec604a7 | ||
|
|
e2238c16f5 | ||
|
|
8c5bb0bc1f | ||
|
|
5c26be43e6 | ||
|
|
82a71ed75d | ||
|
|
7516090e14 | ||
|
|
8874168291 | ||
|
|
19871d55e2 | ||
|
|
c7d69b4fa1 | ||
|
|
5b47c9cde7 | ||
|
|
67bde4c7ec | ||
|
|
a0d5837519 | ||
|
|
fbdbfd744d | ||
|
|
1d262fc936 | ||
|
|
f7b5082983 | ||
|
|
b35440253d | ||
|
|
1691bdadf5 | ||
|
|
5a2d7c07bf | ||
|
|
3d0bb40da6 | ||
|
|
0b0634273a | ||
|
|
ab8dc0ddee | ||
|
|
4136a2a4f2 | ||
|
|
40c7a6a3f7 | ||
|
|
b2861e0369 | ||
|
|
012e28d4ef | ||
|
|
3cc5db7ad1 | ||
|
|
7e97284374 | ||
|
|
b8359a4249 | ||
|
|
b14a820a4c | ||
|
|
929311a943 | ||
|
|
c754badbaa | ||
|
|
eacfd4bc84 | ||
|
|
78b85fd42f | ||
|
|
525618a7a1 | ||
|
|
436196738e | ||
|
|
d95bf4272c | ||
|
|
5401722d88 | ||
|
|
2d69f64121 | ||
|
|
b8f7aa3298 | ||
|
|
64c9269ee2 | ||
|
|
295ea0cde0 | ||
|
|
1f82f0c40d | ||
|
|
31515961e1 | ||
|
|
5d7a609285 | ||
|
|
e3de9a0184 | ||
|
|
ee436ea2d5 | ||
|
|
32d2659658 | ||
|
|
3f31e50b69 | ||
|
|
408b0a0c34 | ||
|
|
39304ff2bd | ||
|
|
bad129d630 | ||
|
|
2ebd0a3e2f | ||
|
|
407325e0a9 | ||
|
|
e3f9bd98de | ||
|
|
b1783d6be3 | ||
|
|
9fceb98f13 | ||
|
|
220d020c76 | ||
|
|
b292a5e309 | ||
|
|
8d708fcdc8 | ||
|
|
85d12d6c68 | ||
|
|
fef26a06b2 | ||
|
|
3b6e70b4dc | ||
|
|
3881d305f9 | ||
|
|
fcddf85245 | ||
|
|
d0e0903d48 | ||
|
|
3efdc1962d | ||
|
|
f26fbc0dc5 | ||
|
|
a1167bac4e | ||
|
|
bb889f7f67 | ||
|
|
1af0196208 | ||
|
|
8b69be645c | ||
|
|
f371a03cc7 | ||
|
|
509b0097fe | ||
|
|
7b36cc53e7 | ||
|
|
ed9d5aef0a | ||
|
|
c4b0f900f8 | ||
|
|
0e9c0d9f6b | ||
|
|
d2414434fb | ||
|
|
2fe8936e85 | ||
|
|
62ba1ecf4a | ||
|
|
e450b38702 | ||
|
|
3d969c25fa | ||
|
|
8932a6bc02 | ||
|
|
e3f0075132 | ||
|
|
b9ddf6a299 | ||
|
|
c3ece970ee | ||
|
|
ac14cd5ebb | ||
|
|
7930da5242 | ||
|
|
58303047aa | ||
|
|
b1c82759bb | ||
|
|
94576b4b76 | ||
|
|
16833a4377 | ||
|
|
44e1720aab | ||
|
|
3485f9b1c8 | ||
|
|
c499a130da | ||
|
|
824ba35c14 | ||
|
|
bb73d531a4 | ||
|
|
5ef9c5b059 | ||
|
|
2550016ac8 | ||
|
|
eaf107d0d2 | ||
|
|
1bfb912d09 | ||
|
|
bd37b653c2 | ||
|
|
15845da3a8 | ||
|
|
72bc6a4d02 | ||
|
|
661ee2616c | ||
|
|
3bc6074ef3 | ||
|
|
232afee3f6 | ||
|
|
b2a4f88980 | ||
|
|
1f0e305527 | ||
|
|
4d9cda3a24 | ||
|
|
283a72308e | ||
|
|
164ee5c523 | ||
|
|
bce1022677 | ||
|
|
1ba3090db1 | ||
|
|
c92800701a | ||
|
|
caff9ce189 | ||
|
|
3375cbc767 | ||
|
|
cc90e437a9 | ||
|
|
29fc5fbda2 | ||
|
|
20aab04fbf | ||
|
|
45c2215dc2 | ||
|
|
aebf44ee7e | ||
|
|
266d3703cd | ||
|
|
a8ded25b35 | ||
|
|
a4397b5204 | ||
|
|
f24a3dd1f5 | ||
|
|
ef13b9259e | ||
|
|
180a63d6dd | ||
|
|
88f28a7d1e | ||
|
|
b2ca7710cd | ||
|
|
21d55561a0 | ||
|
|
8255fe9e55 | ||
|
|
254c518505 | ||
|
|
ebd8368856 | ||
|
|
78561981ad | ||
|
|
d4e266f9c0 | ||
|
|
bb909d866c | ||
|
|
969b6bff0d | ||
|
|
5f3fc460c1 | ||
|
|
581314a8a4 | ||
|
|
50396a666d | ||
|
|
3526d62540 | ||
|
|
6f58dc47cc | ||
|
|
1283f437c3 | ||
|
|
247c0845e0 | ||
|
|
66f184b41b | ||
|
|
0ce5183056 | ||
|
|
dffe974f9e | ||
|
|
05b25d004e | ||
|
|
825ebd874e | ||
|
|
01ccca16e4 | ||
|
|
3d0859ceec | ||
|
|
401aa6c2f8 | ||
|
|
aeb6f48ac4 | ||
|
|
d03c45e580 | ||
|
|
e4352e5bec | ||
|
|
58efe64c5d | ||
|
|
6f4bab41d5 | ||
|
|
d250ed645a | ||
|
|
09584e43b8 | ||
|
|
4ef98b7448 |
@@ -17,8 +17,5 @@ venv/
|
||||
# Unneeded graphics
|
||||
assets/*
|
||||
|
||||
# Unneeded docs
|
||||
docs/*
|
||||
|
||||
# for local testing only
|
||||
testing.sh
|
||||
17
.eslintrc.js
Normal file
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
module.exports = {
|
||||
extends: ['eslint:recommended', 'eslint-config-prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
strict: ['error', 'global'],
|
||||
'no-unused-vars': ['error', { vars: 'local' }],
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
curly: ['error', 'multi-line'],
|
||||
'no-var': 'error',
|
||||
},
|
||||
};
|
||||
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
github: bbilly1
|
||||
ko_fi: bbilly1
|
||||
custom: https://paypal.me/bbilly1
|
||||
55
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Bug Report
|
||||
description: File a bug report
|
||||
title: "[Bug]: "
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to help improve this project! Please read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide carefully before continuing.
|
||||
|
||||
- type: checkboxes
|
||||
id: latest
|
||||
attributes:
|
||||
label: "I've read the documentation"
|
||||
options:
|
||||
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
|
||||
required: true
|
||||
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: On what operating system are you running Tube Archivist on?
|
||||
placeholder: ex. Linux, Unraid, Synology...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Your Bug Report
|
||||
value: "## Describe the bug\n\n## Steps To Reproduce\n\n## Expected behavior"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant Docker logs. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
34
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Feature Request
|
||||
description: I have an idea for a great addition to this project
|
||||
title: "[Feature Request]: "
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to help improve this project! This project is *very* selective with accepting new feature requests. Please read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide carefully before continuing.
|
||||
|
||||
- type: checkboxes
|
||||
id: already
|
||||
attributes:
|
||||
label: "I've read the documentation"
|
||||
options:
|
||||
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [feature request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#feature-request) section.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Your Feature Request
|
||||
value: "## Is your feature request related to a problem? Please describe.\n\n## Describe the solution you'd like\n\n## Additional context"
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: help
|
||||
attributes:
|
||||
label: Your help is needed!
|
||||
description: This project is ambitious as it is, please contribute.
|
||||
options:
|
||||
- label: Yes I will work on this in the next few days or weeks.
|
||||
43
.github/ISSUE_TEMPLATE/INSTALLATION-HELP.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Installation Help
|
||||
description: There is an issue with installing or configuring Tube Archivist
|
||||
title: "[Installation]: "
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This is most likely not the best place to ask for installation help. There is a helpful community on [Discord](https://github.com/tubearchivist/tubearchivist#connect).
|
||||
|
||||
- type: checkboxes
|
||||
id: instructions
|
||||
attributes:
|
||||
label: Installation instructions
|
||||
options:
|
||||
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [installation help](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#installation-help) section.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: On what operating system do you want to install Tube Archivist on?
|
||||
placeholder: ex. Linux, Unraid, Synology...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Your installation or setup question
|
||||
placeholder: Describe your issue
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant Docker logs. This will be automatically formatted into code, so no need for backticks.
|
||||
render: Shell
|
||||
validations:
|
||||
required: true
|
||||
3
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Thank you for taking the time to improve this project. Please take a look at the [How to make a Pull Request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-make-a-pull-request) section to help get your contribution merged.
|
||||
|
||||
You can delete this text before submitting.
|
||||
16
.github/workflows/lint_js.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: lint_js
|
||||
|
||||
on: [pull_request, push]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: lint_js
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run format -- --check
|
||||
21
.github/workflows/lint_python.yml
vendored
@@ -4,20 +4,11 @@ jobs:
|
||||
lint_python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- run: pip install --upgrade pip wheel
|
||||
- run: pip install bandit black codespell flake8 flake8-bugbear
|
||||
flake8-comprehensions isort
|
||||
- run: bandit --recursive --skip B105,B108,B404,B603,B607 .
|
||||
- run: black --check --diff --line-length 79 .
|
||||
- run: codespell
|
||||
- run: flake8 . --count --max-complexity=12 --max-line-length=79
|
||||
--show-source --statistics
|
||||
- run: isort --check-only --line-length 79 --profile black .
|
||||
# - run: pip install -r tubearchivist/requirements.txt
|
||||
# - run: mkdir --parents --verbose .mypy_cache
|
||||
# - run: mypy --ignore-missing-imports --install-types --non-interactive .
|
||||
# - run: python3 tubearchivist/manage.py test || true
|
||||
# - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true
|
||||
# - run: safety check
|
||||
flake8-comprehensions isort requests
|
||||
- run: ./deploy.sh validate
|
||||
|
||||
7
.gitignore
vendored
@@ -1,5 +1,12 @@
|
||||
# python testing cache
|
||||
__pycache__
|
||||
.venv
|
||||
|
||||
# django testing db
|
||||
db.sqlite3
|
||||
|
||||
# vscode custom conf
|
||||
.vscode
|
||||
|
||||
# JavaScript stuff
|
||||
node_modules
|
||||
|
||||
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.pycodestyleEnabled": false,
|
||||
"python.linting.enabled": true
|
||||
}
|
||||
48
ARCHITECTURE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# The Inner Workings of Tube Archivist
|
||||
This is a high level overview of the architecture of Tube Archivist, intended for interested contributors to find your way around quickly.
|
||||
|
||||
```
|
||||
Tube Archivist
|
||||
______________________|_____________________
|
||||
| | |
|
||||
------------------- --------------- -------------------
|
||||
| | | | | |
|
||||
| DjangoProject | | RedisJson | | ElasticSearch |
|
||||
| | | | | |
|
||||
------------------- --------------- -------------------
|
||||
```
|
||||
|
||||
## DjangoProject
|
||||
This is the main Python application. Django serves its data container internally with **Uwsgi** on port 8080, the interface is served with **Nginx** on the public port 8000.
|
||||
|
||||
Users created static files like media files and artwork as well as application artwork like logos and fonts are served directly from Nginx, while the rest of the application uses uwsgi_pass to proxy the requests to uwsgi.
|
||||
|
||||
Config files are located in the `docker_assets` folder. The script `run.sh` is the container `CMD` command and entry point, validating env vars, connection to ElasticSearch (ES) and will start the application.
|
||||
|
||||
Compared to other Django projects, this application doesn't make use of the database models, due to a lack of integration with ES. This project has its own abstractions and integrations, treating ES as a REST API.
|
||||
|
||||
Long running application tasks are handed off to **Celery** - using **Redis** as a broker - to run asynchronously from the main threads.
|
||||
- All tasks are defined in the `home.tasks.py` module.
|
||||
|
||||
There are three Django apps:
|
||||
- **config**: The root app, routing the main endpoints and the main `settings.py` file
|
||||
- **api**: The API app with its views and functionality
|
||||
- **home**: Most of the application logic, templates and views, will probably get split up further in the future.
|
||||
|
||||
The *home* app is split up into packages in the `src` directory:
|
||||
- **download**: All download related classes, interact with yt-dlp, download artwork, handle the download queue and post processing tasks.
|
||||
- **es**: All index setup and validation classes, handles mapping validations and makes mapping changes, wrapper functions to simplify interactions with Elasticsearch, backup and restore.
|
||||
- **frontend**: All direct interactions with the frontend, like Django forms, searching, watched state changes, and legacy api_calls in the process of moving to the api app.
|
||||
- **index**: Contains all functionality for scraping and indexing videos, channels, playlists, comments, subtitles, etc...
|
||||
- **ta**: Loose collection of functions and classes, handle application config and contains redis wrapper classes.
|
||||
|
||||
## RedisJson
|
||||
Holds the main application config json object that gets dynamically edited from the frontend, serves as a message broker for **Celery**. Redis serves as a temporary and thread safe link between Django and the frontend, storing progress messages and temporary queues for processing. Used to store locking keys for threads and execution details for tasks.
|
||||
|
||||
- Wrapper classes to interact with Redis are located in the `home.src.ta.ta_redis.py` module.
|
||||
|
||||
## ElasticSearch (ES)
|
||||
Is used to store and index all metadata, functions as an application database and makes it all searchable. The mapping defines which fields are indexed as searchable text fields and which fields are used for match filtering.
|
||||
|
||||
- The index setup and validation is handled in the `home.src.es.index_setup.py` module.
|
||||
- Wrapper classes for making requests to ES are located in the `home.src.es.connect.py` module.
|
||||
194
CONTRIBUTING.md
@@ -1,53 +1,179 @@
|
||||
## Contributing to Tube Archivist
|
||||
|
||||
Welcome, and thanks for showing interest in improving Tube Archivist!
|
||||
If you haven't already, the best place to start is the README. This will give you an overview on what the project is all about.
|
||||
|
||||
## Report a bug
|
||||
## Table of Content
|
||||
- [How to open an issue](#how-to-open-an-issue)
|
||||
- [Bug Report](#bug-report)
|
||||
- [Feature Request](#feature-request)
|
||||
- [Installation Help](#installation-help)
|
||||
- [How to make a Pull Request](#how-to-make-a-pull-request)
|
||||
- [Contributions beyond the scope](#contributions-beyond-the-scope)
|
||||
- [User Scripts](#user-scripts)
|
||||
- [Improve to the Documentation](#improve-to-the-documentation)
|
||||
- [Development Environment](#development-environment)
|
||||
---
|
||||
|
||||
If you notice something is not working as expected, check to see if it has been previously reported in the [open issues](https://github.com/bbilly1/tubearchivist/issues).
|
||||
If it has not yet been disclosed, go ahead and create an issue.
|
||||
If the issue doesn't move forward due to a lack of response, I assume it's solved and will close it after some time to keep the list fresh.
|
||||
## How to open an issue
|
||||
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
|
||||
|
||||
## Wiki
|
||||
**Do**:
|
||||
- Do provide details and context, this matters a lot and makes it easier for people to help.
|
||||
- Do familiarize yourself with the project first, some questions answer themselves when using the project for some time. Familiarize yourself with the [Readme](https://github.com/tubearchivist/tubearchivist) and the [documentation](https://docs.tubearchivist.com/), this covers a lot of the common questions, particularly the [FAQ](https://docs.tubearchivist.com/faq/).
|
||||
- Do respond to questions within a day or two so issues can progress. If the issue doesn't move forward due to a lack of response, we'll assume it's solved and we'll close it after some time to keep the list fresh.
|
||||
|
||||
WIP: The wiki is where all user functions are explained in detail. These pages are mirrored into the **docs** folder of the repo. This allows for pull requests and all other features like regular code. Make any changes there, and I'll sync them with the wiki tab.
|
||||
**Don't**:
|
||||
- Don't open *duplicates*, that includes open and closed issues.
|
||||
- Don't open an issue for something that's already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), this needs your help to implement it, not another issue.
|
||||
- Don't open an issue for something that's a [known limitation](https://github.com/tubearchivist/tubearchivist#known-limitations). These are *known* by definition and don't need another reminder. Some limitations may be solved in the future, maybe by you?
|
||||
- Don't overwrite the *issue template*, they are there for a reason. Overwriting that shows that you don't really care about this project. It shows that you have a misunderstanding how open source collaboration works and just want to push your ideas through. Overwriting the template may result in a ban.
|
||||
|
||||
### Bug Report
|
||||
Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp.
|
||||
|
||||
Please keep in mind:
|
||||
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
|
||||
- Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after.
|
||||
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
||||
|
||||
### Feature Request
|
||||
This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors to implement these ideas.
|
||||
|
||||
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. Best and fastest way to implement your feature is to do it yourself, that's why this project is open source after all. This project is *very* selective with accepting new feature requests at this point.
|
||||
|
||||
Good feature requests usually fall into one or more of these categories:
|
||||
- You want to work on your own idea within the next few days or weeks.
|
||||
- Your idea is beneficial for a wide range of users, not just for you.
|
||||
- Your idea extends the current project by building on and improving existing functionality.
|
||||
- Your idea is quick and easy to implement, for an experienced as well as for a first time contributor.
|
||||
|
||||
Your request is likely going to be rejected if:
|
||||
- Your idea requires multiple days worth of development time and is unrealistic to be implemented any time soon.
|
||||
- There are already other ways to do what you are trying to do.
|
||||
- You are trying to do something that only applies to your platform, your specific workflow or your specific setup.
|
||||
- Your idea would fundamentally change how the project works or it wouldn't be able to be implemented with backwards compatibility.
|
||||
- Your idea is not a good fit for this project.
|
||||
|
||||
### Installation Help
|
||||
GitHub is most likely not the best place to ask for installation help. That's inherently individual and one on one.
|
||||
1. First step is always, help yourself. Start at the [Readme](https://github.com/tubearchivist/tubearchivist) or the additional platform specific installation pages in the [docs](https://docs.tubearchivist.com/).
|
||||
2. If that doesn't answer your question, open a `#support` thread on [Discord](https://www.tubearchivist.com/discord).
|
||||
3. Only if that is not an option, open an issue here.
|
||||
|
||||
IMPORTANT: When receiving help, contribute back to the community by improving the installation instructions with your newly gained knowledge.
|
||||
|
||||
---
|
||||
|
||||
## How to make a Pull Request
|
||||
|
||||
Thank you for contributing and helping improve this project. This is a quick checklist to help streamline the process:
|
||||
|
||||
- For **code changes**, make your PR against the [testing branch](https://github.com/tubearchivist/tubearchivist/tree/testing). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging.
|
||||
- For **documentation changes**, make your PR directly against the *master* branch.
|
||||
- Show off your progress, even if not yet complete, by creating a [draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests) PR first and switch it as *ready* when you are ready.
|
||||
- Make sure all your code is linted and formatted correctly, see below. The automatic GH action unfortunately needs to be triggered manually by a maintainer for first time contributors, but will trigger automatically for existing contributors.
|
||||
|
||||
### Making changes to the JavaScript
|
||||
|
||||
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
|
||||
|
||||
### Code formatting and linting
|
||||
|
||||
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **video** module, run
|
||||
|
||||
```shell
|
||||
./deploy.sh validate tubearchivist/home/src/index/video.py
|
||||
```
|
||||
|
||||
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.
|
||||
|
||||
---
|
||||
|
||||
## Contributions beyond the scope
|
||||
|
||||
As you have read the [FAQ](https://docs.tubearchivist.com/faq/) and the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and have gotten an idea what this project tries to do, there will be some obvious shortcomings that stand out, that have been explicitly excluded from the scope of this project, at least for the time being.
|
||||
|
||||
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
|
||||
|
||||
Small minor additions, or making a PR for a documented feature request or bug, even if that was and will be your only contribution to this project, are always welcome and is *not* what this is about.
|
||||
|
||||
Beyond that, general rules to consider:
|
||||
|
||||
- Maintainability is key: It's not just about implementing something and being done with it, it's about maintaining it, fixing bugs as they occur, improving on it and supporting it in the long run.
|
||||
- Others can do it better: Some problems have been solved by very talented developers. These things don't need to be reinvented again here in this project.
|
||||
- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only applies to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API.
|
||||
- If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project.
|
||||
|
||||
---
|
||||
|
||||
## User Scripts
|
||||
Some of you might have created useful scripts or API integrations around this project. Sharing is caring! Please add a link to your script to the Readme [here](https://github.com/tubearchivist/tubearchivist#user-scripts).
|
||||
- Your repo should have a `LICENSE` file with one of the common open source licenses. People are expected to fork, adapt and build upon your great work.
|
||||
- Your script should not modify the *official* files of Tube Archivist. E.g. your symlink script should build links *outside* of your `/youtube` folder. Or your fancy script that creates a beautiful artwork gallery should do that *outside* of the `/cache` folder. Modifying the *official* files and folders of TA are probably not supported.
|
||||
- On the top of the repo you should have a mention and a link back to the Tube Archivist repo. Clearly state to **not** to open any issues on the main TA repo regarding your script.
|
||||
- Example template:
|
||||
- `[<user>/<repo>](https://linktoyourrepo.com)`: A short one line description.
|
||||
|
||||
---
|
||||
|
||||
## Improve to the Documentation
|
||||
|
||||
The documentation available at [docs.tubearchivist.com](https://docs.tubearchivist.com/) and is build from a separate repo [tubearchivist/docs](https://github.com/tubearchivist/docs). The Readme has additional instructions on how to make changes.
|
||||
|
||||
---
|
||||
|
||||
## Development Environment
|
||||
|
||||
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment.
|
||||
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment. You might be able to run the application directly, but this document assumes you're using docker.
|
||||
|
||||
This is my setup I have landed on, YMMV:
|
||||
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is the where all the changes are happening, might be unstable and is WIP.
|
||||
- Then I have a VM on KVM hypervisor running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate lowend environments by limiting CPU cores and memory. But you could also just run docker on your host system.
|
||||
- Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
|
||||
- The `Dockerfile` is structured in a way that the actual application code is in the last layer so rebuilding the image with only code changes utilizes the build cache for everything else and will take just 2-3 secs.
|
||||
- Take a look at the `deploy.sh` file. I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
|
||||
### Instructions
|
||||
|
||||
Set up docker on your development machine.
|
||||
|
||||
Clone this repository.
|
||||
|
||||
Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work.
|
||||
|
||||
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
|
||||
|
||||
Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory.
|
||||
|
||||
Make your changes locally and re-run `docker compose up --build`. The `Dockerfile` is structured in a way that the actual application code is in the last layer so rebuilding the image with only code changes utilizes the build cache for everything else and will just take a few seconds.
|
||||
|
||||
### Develop environment inside a VM
|
||||
|
||||
You may find it nice to run everything inside of a VM, though this is not necessary. There's a `deploy.sh` script which has some helpers for this use case. YMMV, this is what one of the developers does:
|
||||
|
||||
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is where all the changes are happening, might be unstable and is WIP.
|
||||
- Then I have a VM running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate low end environments by limiting CPU cores and memory. You can use this [Ansible Docker Ubuntu](https://github.com/bbilly1/ansible-playbooks) playbook to get started quickly. But you could also just run docker on your host system.
|
||||
- I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
|
||||
```bash
|
||||
./deploy.sh test
|
||||
```
|
||||
- The command above will also copy the file `tubarchivist/testing.sh` into the working folder of the container. Running this script will install additional debugging tools I regularly use in testing.
|
||||
- The command above will call the docker build command with `--build-arg INSTALL_DEBUG=1` to install additional useful debug tools.
|
||||
- The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`.
|
||||
- This `deploy.sh` file is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
|
||||
- This `deploy.sh` script is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
|
||||
|
||||
## Implementing a new feature
|
||||
### Working with Elasticsearch
|
||||
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
|
||||
|
||||
Do you see anything on the roadmap that you would like to take a closer look at but you are not sure, what's the best way to tackle that? Or anything not on there yet you'd like to implement but are not sure how? Open up an issue and we try to find a solution together.
|
||||
|
||||
## Making changes
|
||||
|
||||
To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request.
|
||||
|
||||
## Releases
|
||||
|
||||
Everything on the master branch is what's in the latest release and is what you get in your container when you `pull` either the *:latest* tag or the newest named version. If you want to test the newest changes and improvements, clone the repository and build the docker container with the Dockerfile from the testing branch.
|
||||
|
||||
## Code formatting and linting
|
||||
|
||||
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **download** module, run
|
||||
|
||||
```shell
|
||||
./deploy.sh validate tubearchivist/home/src/download.py
|
||||
**Quick start**
|
||||
Generate your access token in Elasitcsearch:
|
||||
```bash
|
||||
bin/elasticsearch-service-tokens create elastic/kibana kibana
|
||||
```
|
||||
|
||||
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.
|
||||
Example docker compose, use same version as for Elasticsearch:
|
||||
```yml
|
||||
services:
|
||||
kibana:
|
||||
image: docker.elastic.co/kibana/kibana:0.0.0
|
||||
container_name: kibana
|
||||
environment:
|
||||
- "ELASTICSEARCH_HOSTS=http://archivist-es:9200"
|
||||
- "ELASTICSEARCH_SERVICEACCOUNTTOKEN=<your-token-here>"
|
||||
ports:
|
||||
- "5601:5601"
|
||||
```
|
||||
|
||||
If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
|
||||
|
||||
75
Dockerfile
@@ -1,46 +1,73 @@
|
||||
# build the tube archivist image from default python slim image
|
||||
# multi stage to build tube archivist
|
||||
# first stage to build python wheel, copy into final image
|
||||
|
||||
FROM python:3.9.7-slim-bullseye
|
||||
|
||||
# First stage to build python wheel
|
||||
FROM python:3.11.3-slim-bullseye AS builder
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
|
||||
|
||||
# install requirements
|
||||
COPY ./tubearchivist/requirements.txt /requirements.txt
|
||||
RUN pip install --user -r requirements.txt
|
||||
|
||||
# build final image
|
||||
FROM python:3.11.3-slim-bullseye as tubearchivist
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG INSTALL_DEBUG
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# copy build requirements
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
# install distro packages needed
|
||||
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
|
||||
build-essential \
|
||||
nginx \
|
||||
curl && rm -rf /var/lib/apt/lists/*
|
||||
atomicparsley \
|
||||
curl \
|
||||
xz-utils && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# get newest patched ffmpeg and ffprobe builds for amd64 fall back to repo ffmpeg for arm64
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \
|
||||
# install patched ffmpeg build, default to linux64
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
|
||||
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
|
||||
| grep browser_download_url \
|
||||
| grep linux64-gpl-4.4.tar.xz \
|
||||
| grep ".*master.*linuxarm64.*tar.xz" \
|
||||
| cut -d '"' -f 4 \
|
||||
| xargs curl -L --output ffmpeg.tar.xz && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
|
||||
rm ffmpeg.tar.xz \
|
||||
; elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
|
||||
apt-get -y update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* \
|
||||
| xargs curl -L --output ffmpeg.tar.xz ; \
|
||||
else \
|
||||
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
|
||||
| grep browser_download_url \
|
||||
| grep ".*master.*linux64.*tar.xz" \
|
||||
| cut -d '"' -f 4 \
|
||||
| xargs curl -L --output ffmpeg.tar.xz ; \
|
||||
fi && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
|
||||
rm ffmpeg.tar.xz
|
||||
|
||||
# install debug tools for testing environment
|
||||
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
||||
apt-get -y update && apt-get -y install --no-install-recommends \
|
||||
vim htop bmon net-tools iputils-ping procps \
|
||||
&& pip install --user ipython \
|
||||
; fi
|
||||
|
||||
# copy config files
|
||||
COPY nginx.conf /etc/nginx/conf.d/
|
||||
|
||||
# make folders
|
||||
RUN mkdir /cache
|
||||
RUN mkdir /youtube
|
||||
RUN mkdir /app
|
||||
RUN mkdir /cache /youtube /app
|
||||
|
||||
# install python dependencies
|
||||
COPY ./tubearchivist/requirements.txt /requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt --src /usr/local/src
|
||||
# copy config files
|
||||
COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
|
||||
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
|
||||
|
||||
# copy application into container
|
||||
COPY ./tubearchivist /app
|
||||
COPY ./run.sh /app
|
||||
COPY ./uwsgi.ini /app
|
||||
COPY ./docker_assets/run.sh /app
|
||||
COPY ./docker_assets/uwsgi.ini /app
|
||||
|
||||
# volumes
|
||||
VOLUME /cache
|
||||
|
||||
262
README.md
@@ -1,149 +1,225 @@
|
||||

|
||||

|
||||
[*more screenshots and video*](SHOWCASE.MD)
|
||||
|
||||
<center><h1>Your self hosted YouTube media server</h1></center>
|
||||
<div align="center">
|
||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-docker.png" alt="tubearchivist-docker" title="Tube Archivist Docker Pulls" height="50" width="190"/></a>
|
||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-star.png" alt="tubearchivist-github-star" title="Tube Archivist GitHub Stars" height="50" width="190"/></a>
|
||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="190"/></a>
|
||||
<a href="https://www.tubearchivist.com/discord" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-discord.png" alt="tubearchivist-discord" title="TA Discord Server Members" height="50" width="190"/></a>
|
||||
</div>
|
||||
|
||||
## Table of contents:
|
||||
* [Wiki](https://github.com/bbilly1/tubearchivist/wiki) for a detailed documentation
|
||||
* [Docs](https://docs.tubearchivist.com/) with [FAQ](https://docs.tubearchivist.com/faq/), and API documentation
|
||||
* [Core functionality](#core-functionality)
|
||||
* [Screenshots](#screenshots)
|
||||
* [Problem Tube Archivist tries to solve](#problem-tube-archivist-tries-to-solve)
|
||||
* [Installing and updating](#installing-and-updating)
|
||||
* [Resources](#resources)
|
||||
* [Installing](#installing)
|
||||
* [Getting Started](#getting-started)
|
||||
* [Potential pitfalls](#potential-pitfalls)
|
||||
* [Roadmap](#roadmap)
|
||||
* [Known limitations](#known-limitations)
|
||||
* [Port Collisions](#port-collisions)
|
||||
* [Common Errors](#common-errors)
|
||||
* [Roadmap](#roadmap)
|
||||
* [Donate](#donate)
|
||||
|
||||
------------------------
|
||||
|
||||
## Core functionality
|
||||
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
|
||||
* Subscribe to your favorite YouTube channels
|
||||
* Download Videos using **yt-dlp**
|
||||
* Index and make videos searchable
|
||||
* Play videos
|
||||
* Keep track of viewed and unviewed videos
|
||||
|
||||
## Screenshots
|
||||

|
||||
*Home Page*
|
||||
|
||||

|
||||
*All Channels*
|
||||
|
||||

|
||||
*Single Channel*
|
||||
|
||||

|
||||
*Video Page*
|
||||
|
||||

|
||||
*Downloads Page*
|
||||
|
||||
## Problem Tube Archivist tries to solve
|
||||
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface.
|
||||
## Resources
|
||||
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
||||
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
|
||||
- [Browser Extension](https://github.com/tubearchivist/browser-extension) Tube Archivist Companion, for [Firefox](https://addons.mozilla.org/addon/tubearchivist-companion/) and [Chrome](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie)
|
||||
- [Jellyfin Integration](https://github.com/tubearchivist/tubearchivist-jf): Add your videos to Jellyfin.
|
||||
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
|
||||
|
||||
## Installing and updating
|
||||
Take a look at the example `docker-compose.yml` file provided. Tube Archivist depends on three main components split up into separate docker containers:
|
||||
## Installing
|
||||
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
|
||||
This project requires docker. Ensure it is installed and running on your system.
|
||||
|
||||
### Tube Archivist
|
||||
The main Python application that displays and serves your video collection, built with Django.
|
||||
- Serves the interface on port `8000`
|
||||
- Needs a mandatory volume for the video archive at **/youtube**
|
||||
- And another recommended volume to save the cache for thumbnails and artwork at **/cache**.
|
||||
- The environment variables `ES_URL` and `REDIS_HOST` are needed to tell Tube Archivist where Elasticsearch and Redis respectively are located.
|
||||
- The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user.
|
||||
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/), [Podman](https://docs.tubearchivist.com/installation/podman/) and [True NAS](https://docs.tubearchivist.com/installation/truenas-scale/).
|
||||
|
||||
### Elasticsearch
|
||||
Stores video meta data and makes everything searchable. Also keeps track of the download queue.
|
||||
- Needs to be accessible over the default port `9200`
|
||||
- Needs a volume at **/usr/share/elasticsearch/data** to store data
|
||||
The instructions here should get you up and running quickly, for Docker beginners and full explanation about each environment variable, see the [docs](https://docs.tubearchivist.com/installation/docker-compose/).
|
||||
|
||||
Follow the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html) for additional installation details.
|
||||
Take a look at the example [docker-compose.yml](https://github.com/tubearchivist/tubearchivist/blob/master/docker-compose.yml) and configure the required environment variables.
|
||||
|
||||
### Redis JSON
|
||||
Functions as a cache and temporary link between the application and the file system. Used to store and display messages and configuration variables.
|
||||
- Needs to be accessible over the default port `6379`
|
||||
- Takes an optional volume at **/data** to make your configuration changes permanent.
|
||||
**TubeArchivist**:
|
||||
| Environment Var | Value | |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| TA_HOST | Server IP or hostname | Required |
|
||||
| TA_USERNAME | Initial username when logging into TA | Required |
|
||||
| TA_PASSWORD | Initial password when logging into TA | Required |
|
||||
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
|
||||
| REDIS_HOST | Hostname for Redis | Required |
|
||||
| TZ | Set your timezone for the scheduler | Required |
|
||||
| TA_PORT | Overwrite Nginx port | Optional |
|
||||
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional |
|
||||
| TA_ENABLE_AUTH_PROXY | Enables support for forwarding auth in reverse proxies | [Read more](https://docs.tubearchivist.com/configuration/forward-auth/) |
|
||||
| TA_AUTH_PROXY_USERNAME_HEADER | Header containing username to log in | Optional |
|
||||
| TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional |
|
||||
| ES_URL | URL That ElasticSearch runs on | Optional |
|
||||
| ES_DISABLE_VERIFY_SSL | Disable ElasticSearch SSL certificate verification | Optional |
|
||||
| ES_SNAPSHOT_DIR | Custom path where elastic search stores snapshots for master/data nodes | Optional |
|
||||
| HOST_GID | Allow TA to own the video files instead of container user | Optional |
|
||||
| HOST_UID | Allow TA to own the video files instead of container user | Optional |
|
||||
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
|
||||
| REDIS_PORT | Port that Redis runs on | Optional |
|
||||
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
|
||||
| ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) |
|
||||
| DJANGO_DEBUG | Return additional error messages, for debug only | |
|
||||
|
||||
### Redis on a custom port
|
||||
For some architectures it might be required to run Redis JSON on a nonstandard port. To for example change the Redis port to **6380**, set the following values:
|
||||
- Set the environment variable `REDIS_PORT=6380` to the *tubearchivist* service.
|
||||
- For the *archivist-redis* service, change the ports to `6380:6380`
|
||||
- Additionally set the following value to the *archivist-redis* service: `command: --port 6380 --loadmodule /usr/lib/redis/modules/rejson.so`
|
||||
**ElasticSearch**
|
||||
| Environment Var | Value | State |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
||||
| http.port | Change the port ElasticSearch runs on | Optional |
|
||||
|
||||
### Updating Tube Archivist
|
||||
You will see the current version number of **Tube Archivist** in the footer of the interface so you can compare it with the latest release to make sure you are running the *latest and greatest*.
|
||||
* There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
|
||||
* All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned.
|
||||
|
||||
### Alternative installation instructions:
|
||||
- **arm64**: Newest Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, you can use `bbilly1/rejson`, an unofficial rebuild for arm64.
|
||||
- NOTE: This is untested, looking for feedback.
|
||||
- **Synology**: There is a [discussion thread](https://github.com/bbilly1/tubearchivist/discussions/48) with Synology installation instructions.
|
||||
- **Unraid**: There is a [template](https://github.com/pairofcrocs/unraid-templates/) and forum [thread](https://forums.unraid.net/topic/114073-support-crocs-tube-archivist/) with additional information.
|
||||
## Update
|
||||
Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags are only for your testing environment, there might not be an update path for these testing builds.
|
||||
|
||||
## Potential pitfalls
|
||||
### vm.max_map_count
|
||||
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version.
|
||||
|
||||
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month.
|
||||
- There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
|
||||
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
|
||||
|
||||
## Getting Started
|
||||
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
|
||||
```
|
||||
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
|
||||
```
|
||||
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
|
||||
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
|
||||
4. Click on *Start download* and let **Tube Archivist** to it's thing.
|
||||
5. Enjoy your archived collection!
|
||||
|
||||
|
||||
### Port Collisions
|
||||
If you have a collision on port `8000`, best solution is to use dockers *HOST_PORT* and *CONTAINER_PORT* distinction: To for example change the interface to port 9000 use `9000:8000` in your docker-compose file.
|
||||
|
||||
For more information on port collisions, check the docs.
|
||||
|
||||
## Common Errors
|
||||
Here is a list of common errors and their solutions.
|
||||
|
||||
### `vm.max_map_count`
|
||||
**Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144.
|
||||
|
||||
To temporary set the value run:
|
||||
```
|
||||
sudo sysctl -w vm.max_map_count=262144
|
||||
```
|
||||
|
||||
To apply the change permanently depends on your host operating system:
|
||||
- For example on Ubuntu Server add `vm.max_map_count = 262144` to the file */etc/sysctl.conf*.
|
||||
- On Arch based systems create a file */etc/sysctl.d/max_map_count.conf* with the content `vm.max_map_count = 262144`.
|
||||
- On any other platform look up in the documentation on how to pass kernel parameters.
|
||||
|
||||
- For example on Ubuntu Server add `vm.max_map_count = 262144` to the file `/etc/sysctl.conf`.
|
||||
- On Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
|
||||
- On any other platform look up in the documentation on how to pass kernel parameters.
|
||||
|
||||
|
||||
### Permissions for elasticsearch
|
||||
If you see a message similar to `AccessDeniedException[/usr/share/elasticsearch/data/nodes]` when initially starting elasticsearch, that means the container is not allowed to write files to the volume.
|
||||
That's most likely the case when you run `docker-compose` as an unprivileged user. To fix that issue, shutdown the container and on your host machine run:
|
||||
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
|
||||
To fix that issue, shutdown the container and on your host machine run:
|
||||
```
|
||||
chown 1000:0 /path/to/mount/point
|
||||
chown 1000:0 -R /path/to/mount/point
|
||||
```
|
||||
This will match the permissions with the **UID** and **GID** of elasticsearch within the container and should fix the issue.
|
||||
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
|
||||
|
||||
|
||||
### Disk usage
|
||||
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
|
||||
|
||||
Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download.
|
||||
|
||||
## `error setting rlimit`
|
||||
If you are seeing errors like `failed to create shim: OCI runtime create failed` and `error during container init: error setting rlimits`, this means docker can't set these limits, usually because they are set at another place or are incompatible because of other reasons. Solution is to remove the `ulimits` key from the ES container in your docker compose and start again.
|
||||
|
||||
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
|
||||
|
||||
## Known limitations
|
||||
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
|
||||
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
|
||||
- There is no flexibility in naming of the media files.
|
||||
|
||||
## Getting Started
|
||||
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default.
|
||||
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
|
||||
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
|
||||
4. Click on *Start download* and let **Tube Archivist** to it's thing.
|
||||
5. Enjoy your archived collection!
|
||||
|
||||
## Roadmap
|
||||
This should be considered as a **minimal viable product**, there is an extensive list of future functions and improvements planned.
|
||||
We have come far, nonetheless we are not short of ideas on how to improve and extend this project. Issues waiting for you to be tackled in no particular order:
|
||||
|
||||
### Functionality
|
||||
- [ ] Access control
|
||||
- [ ] User roles
|
||||
- [ ] Create playlists
|
||||
- [ ] Audio download
|
||||
- [ ] Podcast mode to serve channel as mp3
|
||||
- [ ] Implement [PyFilesystem](https://github.com/PyFilesystem/pyfilesystem2) for flexible video storage
|
||||
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
||||
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
|
||||
- [ ] Multi language support
|
||||
- [ ] Show total video downloaded vs total videos available in channel
|
||||
- [ ] Download or Ignore videos by keyword ([#163](https://github.com/tubearchivist/tubearchivist/issues/163))
|
||||
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
|
||||
- [ ] Search comments
|
||||
- [ ] Search download queue
|
||||
- [ ] Configure shorts, streams and video sizes per channel
|
||||
|
||||
Implemented:
|
||||
- [X] Add statistics of index [2023-09-03]
|
||||
- [X] Implement [Apprise](https://github.com/caronc/apprise) for notifications [2023-08-05]
|
||||
- [X] Download video comments [2022-11-30]
|
||||
- [X] Show similar videos on video page [2022-11-30]
|
||||
- [X] Implement complete offline media file import from json file [2022-08-20]
|
||||
- [X] Filter and query in search form, search by url query [2022-07-23]
|
||||
- [X] Make items in grid row configurable to use more of the screen [2022-06-04]
|
||||
- [X] Add passing browser cookies to yt-dlp [2022-05-08]
|
||||
- [X] Add [SponsorBlock](https://sponsor.ajay.app/) integration [2022-04-16]
|
||||
- [X] Implement per channel settings [2022-03-26]
|
||||
- [X] Subtitle download & indexing [2022-02-13]
|
||||
- [X] Fancy advanced unified search interface [2022-01-08]
|
||||
- [X] Auto rescan and auto download on a schedule [2021-12-17]
|
||||
- [X] Optional automatic deletion of watched items after a specified time [2021-12-17]
|
||||
- [X] Create playlists [2021-11-27]
|
||||
- [X] Access control [2021-11-01]
|
||||
- [X] Delete videos and channel [2021-10-16]
|
||||
- [X] Add thumbnail embed option [2021-10-16]
|
||||
- [X] Create a github wiki for user documentation [2021-10-03]
|
||||
- [X] Grid and list view for both channel and video list pages [2021-10-03]
|
||||
- [X] Un-ignore videos [2021-10-03]
|
||||
- [X] Dynamic download queue [2021-09-26]
|
||||
- [X] Backup and restore [2021-09-22]
|
||||
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
||||
|
||||
### UI
|
||||
- [ ] Show similar videos on video page
|
||||
- [ ] Multi language support
|
||||
- [ ] Show total video downloaded vs total videos available in channel
|
||||
- [X] Grid and list view for both channel and video list pages [2021-10-03]
|
||||
- [X] Create a github wiki for user documentation [2021-10-03]
|
||||
|
||||
|
||||
## Known limitations
|
||||
- Video files created by Tube Archivist need to be **mp4** video files for best browser compatibility.
|
||||
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
|
||||
- For now this is meant to be run in a trusted network environment. There is *no* security.
|
||||
## User Scripts
|
||||
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
|
||||
|
||||
- Example 1
|
||||
|
||||
## Donate
|
||||
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
||||
Second best way to support the development is to provide for caffeinated beverages:
|
||||
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
|
||||
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
|
||||
* [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee
|
||||
* [co-fi.com](https://ko-fi.com/bbilly1) for an alternative platform
|
||||
* [ko-fi.com](https://ko-fi.com/bbilly1) for an alternative platform
|
||||
|
||||
## Notable mentions
|
||||
This is a selection of places where this project has been featured on reddit, in the news, blogs or any other online media, newest on top.
|
||||
* **ycombinator**: Tube Archivist on Hackernews front page, [2023-07-16][[link](https://news.ycombinator.com/item?id=36744395)]
|
||||
* **linux-community.de**: Tube Archivist bringt Ordnung in die Youtube-Sammlung, [German][2023-05-01][[link](https://www.linux-community.de/ausgaben/linuxuser/2023/05/tube-archivist-bringt-ordnung-in-die-youtube-sammlung/)]
|
||||
* **noted.lol**: Dev Debrief, An Interview With the Developer of Tube Archivist, [2023-03-30] [[link](https://noted.lol/dev-debrief-tube-archivist/)]
|
||||
* **console.substack.com**: Interview With Simon of Tube Archivist, [2023-01-29] [[link](https://console.substack.com/p/console-142#%C2%A7interview-with-simon-of-tube-archivist)]
|
||||
* **reddit.com**: Tube Archivist v0.3.0 - Now Archiving Comments, [2022-12-02] [[link](https://www.reddit.com/r/selfhosted/comments/zaonzp/tube_archivist_v030_now_archiving_comments/)]
|
||||
* **reddit.com**: Tube Archivist v0.2 - Now with Full Text Search, [2022-07-24] [[link](https://www.reddit.com/r/selfhosted/comments/w6jfa1/tube_archivist_v02_now_with_full_text_search/)]
|
||||
* **noted.lol**: How I Control What Media My Kids Watch Using Tube Archivist, [2022-03-27] [[link](https://noted.lol/how-i-control-what-media-my-kids-watch-using-tube-archivist/)]
|
||||
* **thehomelab.wiki**: Tube Archivist - A Youtube-DL Alternative on Steroids, [2022-01-27] [[link](https://thehomelab.wiki/books/news/page/tube-archivist-a-youtube-dl-alternative-on-steroids)]
|
||||
* **reddit.com**: Celebrating TubeArchivist v0.1, [2022-01-09] [[link](https://www.reddit.com/r/selfhosted/comments/rzh084/celebrating_tubearchivist_v01/)]
|
||||
* **linuxunplugged.com**: Pick: tubearchivist — Your self-hosted YouTube media server, [2021-09-11] [[link](https://linuxunplugged.com/425)] and [2021-10-05] [[link](https://linuxunplugged.com/426)]
|
||||
* **reddit.com**: Introducing Tube Archivist, your self hosted Youtube media server, [2021-09-12] [[link](https://www.reddit.com/r/selfhosted/comments/pmj07b/introducing_tube_archivist_your_self_hosted/)]
|
||||
|
||||
|
||||
## Sponsor
|
||||
Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously donating credit for the tubearchivist.com VPS and buildserver.
|
||||
<p>
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
25
SHOWCASE.MD
Normal file
@@ -0,0 +1,25 @@
|
||||
## Tube Archivist on YouTube
|
||||
[](https://www.youtube.com/watch?v=O8H8Z01c0Ys)
|
||||
Video featuring Tube Archivist generously created by [IBRACORP](https://www.youtube.com/@IBRACORP).
|
||||
|
||||
## Screenshots
|
||||

|
||||
*Login Page*: Secure way to access your media collection.
|
||||
|
||||

|
||||
*Home Page*: Your recent videos, continue watching incomplete videos.
|
||||
|
||||

|
||||
*All Channels*: A list of all your indexed channels, filtered by subscribed only.
|
||||
|
||||

|
||||
*Single Channel*: Single channel page with additional metadata and sub pages.
|
||||
|
||||

|
||||
*Video Page*: Stream your video directly from the interface.
|
||||
|
||||

|
||||
*Downloads Page*: Add, control, and monitor your download queue.
|
||||
|
||||

|
||||
*Search Page*. Use expressions to quickly search through your collection.
|
||||
|
Before Width: | Height: | Size: 49 KiB |
BIN
assets/tube-archivist-channels.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
assets/tube-archivist-download.png
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
assets/tube-archivist-front.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/tube-archivist-home.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
assets/tube-archivist-ibracorp-O8H8Z01c0Ys.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
assets/tube-archivist-login.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 81 KiB |
BIN
assets/tube-archivist-search.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/tube-archivist-single-channel.png
Normal file
|
After Width: | Height: | Size: 716 KiB |
BIN
assets/tube-archivist-video.png
Normal file
|
After Width: | Height: | Size: 684 KiB |
155
deploy.sh
@@ -3,33 +3,35 @@
|
||||
# deploy all needed project files to different servers:
|
||||
# test for local vm for testing
|
||||
# blackhole for local production
|
||||
# docker to publish
|
||||
# unstable to publish intermediate releases
|
||||
# docker to publish regular release
|
||||
|
||||
# create builder:
|
||||
# docker buildx create --name tubearchivist
|
||||
# docker buildx use tubearchivist
|
||||
# docker buildx inspect --bootstrap
|
||||
|
||||
# more details:
|
||||
# https://github.com/tubearchivist/tubearchivist/issues/6
|
||||
|
||||
set -e
|
||||
|
||||
function sync_blackhole {
|
||||
|
||||
# docker commands need sudo, only build amd64
|
||||
host="blackhole.local"
|
||||
|
||||
read -sp 'Password: ' remote_pw
|
||||
export PASS=$remote_pw
|
||||
|
||||
rsync -a --progress --delete-after \
|
||||
--exclude ".git" \
|
||||
--exclude ".gitignore" \
|
||||
--exclude "**/cache" \
|
||||
--exclude "**/__pycache__/" \
|
||||
--exclude ".venv" \
|
||||
--exclude "db.sqlite3" \
|
||||
--exclude ".mypy_cache" \
|
||||
. -e ssh "$host":tubearchivist
|
||||
|
||||
echo "$PASS" | ssh "$host" 'sudo -S docker buildx build --platform linux/amd64 -t bbilly1/tubearchivist:latest tubearchivist --load 2>/dev/null'
|
||||
echo "$PASS" | ssh "$host" 'sudo -S docker-compose up -d 2>/dev/null'
|
||||
ssh "$host" 'docker build -t bbilly1/tubearchivist --build-arg TARGETPLATFORM="linux/amd64" tubearchivist'
|
||||
ssh "$host" 'docker compose up -d'
|
||||
|
||||
}
|
||||
|
||||
@@ -39,16 +41,22 @@ function sync_test {
|
||||
# pass argument to build for specific platform
|
||||
|
||||
host="tubearchivist.local"
|
||||
# make base folder
|
||||
ssh "$host" "mkdir -p docker"
|
||||
|
||||
# copy project files to build image
|
||||
rsync -a --progress --delete-after \
|
||||
--exclude ".git" \
|
||||
--exclude ".gitignore" \
|
||||
--exclude "**/cache" \
|
||||
--exclude "**/__pycache__/" \
|
||||
--exclude ".venv" \
|
||||
--exclude "db.sqlite3" \
|
||||
--exclude ".mypy_cache" \
|
||||
. -e ssh "$host":tubearchivist
|
||||
|
||||
rsync -r --progress --delete docker-compose.yml -e ssh "$host":docker
|
||||
# copy default docker-compose file if not exist
|
||||
rsync --progress --ignore-existing docker-compose.yml -e ssh "$host":docker
|
||||
|
||||
if [[ $1 = "amd64" ]]; then
|
||||
platform="linux/amd64"
|
||||
@@ -60,11 +68,8 @@ function sync_test {
|
||||
platform="linux/amd64"
|
||||
fi
|
||||
|
||||
ssh "$host" "docker buildx build --platform $platform -t bbilly1/tubearchivist:latest tubearchivist --load"
|
||||
ssh "$host" 'docker-compose -f docker/docker-compose.yml up -d'
|
||||
|
||||
ssh "$host" 'docker cp tubearchivist/tubearchivist/testing.sh tubearchivist:/app/testing.sh'
|
||||
ssh "$host" 'docker exec tubearchivist chmod +x /app/testing.sh'
|
||||
ssh "$host" "docker buildx build --build-arg INSTALL_DEBUG=1 --platform $platform -t bbilly1/tubearchivist:latest tubearchivist --load"
|
||||
ssh "$host" 'docker compose -f docker/docker-compose.yml up -d'
|
||||
|
||||
}
|
||||
|
||||
@@ -80,23 +85,68 @@ function validate {
|
||||
fi
|
||||
|
||||
echo "run validate on $check_path"
|
||||
|
||||
echo "running bandit"
|
||||
bandit --recursive --skip B105,B108,B404,B603,B607 "$check_path"
|
||||
|
||||
# note: this logic is duplicated in the `./github/workflows/lint_python.yml` config
|
||||
# if you update this file, you should update that as well
|
||||
echo "running black"
|
||||
black --diff --color --check -l 79 "$check_path"
|
||||
black --force-exclude "migrations/*" --diff --color --check -l 79 "$check_path"
|
||||
echo "running codespell"
|
||||
codespell --skip="./.git" "$check_path"
|
||||
codespell --skip="./.git,./.venv,./package.json,./package-lock.json,./node_modules,./.mypy_cache" "$check_path"
|
||||
echo "running flake8"
|
||||
flake8 "$check_path" --count --max-complexity=12 --max-line-length=79 \
|
||||
--show-source --statistics
|
||||
flake8 "$check_path" --exclude "migrations,.venv" --count --max-complexity=10 \
|
||||
--max-line-length=79 --show-source --statistics
|
||||
echo "running isort"
|
||||
isort --check-only --diff --profile black -l 79 "$check_path"
|
||||
isort --skip "migrations" --skip ".venv" --check-only --diff --profile black -l 79 "$check_path"
|
||||
printf " \n> all validations passed\n"
|
||||
|
||||
}
|
||||
|
||||
|
||||
# update latest tag compatible es for set and forget
|
||||
function sync_latest_es {
|
||||
|
||||
VERSION=$(grep "bbilly1/tubearchivist-es" docker-compose.yml | awk '{print $NF}')
|
||||
printf "\nsync new ES version %s\nContinue?\n" "$VERSION"
|
||||
read -rn 1
|
||||
|
||||
if [[ $(systemctl is-active docker) != 'active' ]]; then
|
||||
echo "starting docker"
|
||||
sudo systemctl start docker
|
||||
fi
|
||||
|
||||
sudo docker image pull docker.elastic.co/elasticsearch/elasticsearch:"$VERSION"
|
||||
|
||||
sudo docker tag \
|
||||
docker.elastic.co/elasticsearch/elasticsearch:"$VERSION" \
|
||||
bbilly1/tubearchivist-es
|
||||
|
||||
sudo docker tag \
|
||||
docker.elastic.co/elasticsearch/elasticsearch:"$VERSION" \
|
||||
bbilly1/tubearchivist-es:"$VERSION"
|
||||
|
||||
sudo docker push bbilly1/tubearchivist-es
|
||||
sudo docker push bbilly1/tubearchivist-es:"$VERSION"
|
||||
|
||||
}
|
||||
|
||||
|
||||
# publish unstable tag to docker
|
||||
function sync_unstable {
|
||||
|
||||
if [[ $(systemctl is-active docker) != 'active' ]]; then
|
||||
echo "starting docker"
|
||||
sudo systemctl start docker
|
||||
fi
|
||||
|
||||
# start amd64 build
|
||||
sudo docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
-t bbilly1/tubearchivist:unstable --push .
|
||||
|
||||
}
|
||||
|
||||
|
||||
# new function, sync only tag, build with build server
|
||||
function sync_docker {
|
||||
|
||||
# check things
|
||||
@@ -105,48 +155,63 @@ function sync_docker {
|
||||
return
|
||||
fi
|
||||
|
||||
echo "latest tags:"
|
||||
git tag | tail -n 5 | sort -r
|
||||
|
||||
printf "\ncreate new version:\n"
|
||||
read -r VERSION
|
||||
|
||||
echo "push new tag: $VERSION?"
|
||||
read -rn 1
|
||||
|
||||
# create release tag
|
||||
echo "commits since last version:"
|
||||
git log "$(git describe --tags --abbrev=0)"..HEAD --oneline
|
||||
git tag -a "$VERSION" -m "new release version $VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
}
|
||||
|
||||
|
||||
# old builder, sync tag, build and push locally
|
||||
function sync_docker_old {
|
||||
|
||||
# check things
|
||||
if [[ $(git branch --show-current) != 'master' ]]; then
|
||||
echo 'you are not on master, dummy!'
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $(systemctl is-active docker) != 'active' ]]; then
|
||||
echo "starting docker"
|
||||
sudo systemctl start docker
|
||||
fi
|
||||
|
||||
echo "latest tags:"
|
||||
git tag
|
||||
|
||||
echo "latest docker images:"
|
||||
sudo docker image ls bbilly1/tubearchivist
|
||||
git tag | tail -n 5 | sort -r
|
||||
|
||||
printf "\ncreate new version:\n"
|
||||
read -r VERSION
|
||||
|
||||
# start build
|
||||
sudo docker buildx build --platform linux/amd64,linux/arm64 -t bbilly1/tubearchivist:latest -t bbilly1/tubearchivist:"$VERSION" .
|
||||
|
||||
printf "\nlatest images:\n"
|
||||
sudo docker image ls bbilly1/tubearchivist
|
||||
|
||||
echo "continue?"
|
||||
echo "build and push $VERSION?"
|
||||
read -rn 1
|
||||
|
||||
# push to docker
|
||||
echo "pushing multiarch latest and $VERSION:"
|
||||
sudo docker buildx build --platform linux/amd64,linux/arm64 -t bbilly1/tubearchivist:latest -t bbilly1/tubearchivist:"$VERSION" --push .
|
||||
#sudo docker push bbilly1/tubearchivist:latest
|
||||
#sudo docker push bbilly1/tubearchivist:"$VERSION"
|
||||
# start build
|
||||
sudo docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t bbilly1/tubearchivist \
|
||||
-t bbilly1/tubearchivist:unstable \
|
||||
-t bbilly1/tubearchivist:"$VERSION" --push .
|
||||
|
||||
# create release tag
|
||||
echo "commits since last version:"
|
||||
git log "$(git describe --tags --abbrev=0)"..HEAD --oneline
|
||||
git tag -a "$VERSION" -m "new release version $VERSION"
|
||||
git push all "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
}
|
||||
|
||||
|
||||
# check package versions in requirements.txt for updates
|
||||
python version_check.py
|
||||
|
||||
|
||||
if [[ $1 == "blackhole" ]]; then
|
||||
sync_blackhole
|
||||
elif [[ $1 == "test" ]]; then
|
||||
@@ -155,8 +220,12 @@ elif [[ $1 == "validate" ]]; then
|
||||
validate "$2"
|
||||
elif [[ $1 == "docker" ]]; then
|
||||
sync_docker
|
||||
elif [[ $1 == "unstable" ]]; then
|
||||
sync_unstable
|
||||
elif [[ $1 == "es" ]]; then
|
||||
sync_latest_es
|
||||
else
|
||||
echo "valid options are: blackhole | test | validate | docker"
|
||||
echo "valid options are: blackhole | test | validate | docker | unstable | es"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -1,45 +1,65 @@
|
||||
version: '3.3'
|
||||
version: '3.5'
|
||||
|
||||
services:
|
||||
tubearchivist:
|
||||
container_name: tubearchivist
|
||||
restart: always
|
||||
image: bbilly1/tubearchivist:latest
|
||||
restart: unless-stopped
|
||||
image: bbilly1/tubearchivist
|
||||
ports:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- ./volumes/tubearchivist/media:/youtube
|
||||
- ./volumes/tubearchivist/cache:/cache
|
||||
- media:/youtube
|
||||
- cache:/cache
|
||||
environment:
|
||||
- ES_URL=http://archivist-es:9200
|
||||
- REDIS_HOST=archivist-redis
|
||||
- ES_URL=http://archivist-es:9200 # needs protocol e.g. http and port
|
||||
- REDIS_HOST=archivist-redis # don't add protocol
|
||||
- HOST_UID=1000
|
||||
- HOST_GID=1000
|
||||
- TA_HOST=tubearchivist.local # set your host name
|
||||
- TA_USERNAME=tubearchivist # your initial TA credentials
|
||||
- TA_PASSWORD=verysecret # your initial TA credentials
|
||||
- ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
|
||||
- TZ=America/New_York # set your time zone
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 2m
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
- archivist-es
|
||||
- archivist-redis
|
||||
archivist-redis:
|
||||
image: redislabs/rejson:latest
|
||||
image: redis/redis-stack-server
|
||||
container_name: archivist-redis
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- ./volumes/tubearchivist/redis:/data
|
||||
- redis:/data
|
||||
depends_on:
|
||||
- archivist-es
|
||||
archivist-es:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.15.1
|
||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.11.0
|
||||
container_name: archivist-es
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- "discovery.type=single-node"
|
||||
- "ELASTIC_PASSWORD=verysecret" # matching Elasticsearch password
|
||||
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||
- "xpack.security.enabled=true"
|
||||
- "discovery.type=single-node"
|
||||
- "path.repo=/usr/share/elasticsearch/data/snapshot"
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
volumes:
|
||||
- ./volumes/tubearchivist/es:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- 9200:9200
|
||||
- es:/usr/share/elasticsearch/data # check for permission error when using bind mount, see readme
|
||||
expose:
|
||||
- "9200"
|
||||
|
||||
volumes:
|
||||
media:
|
||||
cache:
|
||||
redis:
|
||||
es:
|
||||
|
||||
33
docker_assets/nginx.conf
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
|
||||
listen 8000;
|
||||
|
||||
location /cache/videos/ {
|
||||
auth_request /api/ping/;
|
||||
alias /cache/videos/;
|
||||
}
|
||||
|
||||
location /cache/channels/ {
|
||||
auth_request /api/ping/;
|
||||
alias /cache/channels/;
|
||||
}
|
||||
|
||||
location /cache/playlists/ {
|
||||
auth_request /api/ping/;
|
||||
alias /cache/playlists/;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
auth_request /api/ping/;
|
||||
alias /youtube/;
|
||||
types {
|
||||
text/vtt vtt;
|
||||
}
|
||||
}
|
||||
|
||||
location / {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass localhost:8080;
|
||||
}
|
||||
|
||||
}
|
||||
24
docker_assets/run.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# startup script inside the container for tubearchivist
|
||||
|
||||
set -e
|
||||
|
||||
# django setup
|
||||
python manage.py migrate
|
||||
|
||||
if [[ -z "$DJANGO_DEBUG" ]]; then
|
||||
python manage.py collectstatic --noinput -c
|
||||
fi
|
||||
|
||||
# ta setup
|
||||
python manage.py ta_envcheck
|
||||
python manage.py ta_connection
|
||||
python manage.py ta_startup
|
||||
python manage.py ta_migpath
|
||||
|
||||
# start all tasks
|
||||
nginx &
|
||||
celery -A home.tasks worker --loglevel=INFO &
|
||||
celery -A home beat --loglevel=INFO \
|
||||
-s "${BEAT_SCHEDULE_PATH:-${cachedir}/celerybeat-schedule}" &
|
||||
uwsgi --ini uwsgi.ini
|
||||
11
docker_assets/uwsgi.ini
Normal file
@@ -0,0 +1,11 @@
|
||||
[uwsgi]
|
||||
module = config.wsgi:application
|
||||
master = True
|
||||
pidfile = /tmp/project-master.pid
|
||||
vacuum = True
|
||||
max-requests = 5000
|
||||
socket = :8080
|
||||
buffer-size = 8192
|
||||
log-5xx = true
|
||||
log-4xx = true
|
||||
disable-logging = true
|
||||
@@ -1,24 +0,0 @@
|
||||
# Channels Overview and Channel Detail Page
|
||||
|
||||
The channels are organized on two different levels:
|
||||
|
||||
## Channels Overview
|
||||
Accessible at `/channel/` of your Tube Archivist, the **Overview Page** shows a list of all channels you have indexed.
|
||||
- You can filter that list to show or hide subscribed channels from the drop down menu. Clicking on the channel banner or the channel name will direct you to the *Channel Detail Page*.
|
||||
- If you are subscribed to a channel a *Unsubscribe* button will show.
|
||||
|
||||
The **Subscribe to Channels** button <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to subscribe to a channel. You have a few options:
|
||||
- Enter the YouTube channel ID, a 25 character alphanumeric string. For example *UCBa659QWEk1AI4Tg--mrJ2A*
|
||||
- Enter the URL to the channel page on YouTube. For example *https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A*
|
||||
- Enter the video URL for any video and let Tube Archivist extract the channel ID for you. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*
|
||||
- Add one per line.
|
||||
- **Note**: Adding a link to a YouTube channel name is not yet supported, for example: *https://www.youtube.com/c/TomScottGo* will fail.
|
||||
|
||||
The search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;"> opens a text box to search for indexed channel names. Possible matches will show as you type.
|
||||
|
||||
## Channel Detail
|
||||
Each channel will get a dedicated channel detail page accessible at `/channel/<channel-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this channel plus additional metadata.
|
||||
- If you are subscribed to the channel, an *Unsubscribe* button will show.
|
||||
- You can *Show* the channel description, that matches with the *About* tab on YouTube.
|
||||
- The **Mark as Watched** button will mark all videos of this channel as watched.
|
||||
- The button **Delete Channel** will delete the channel plus all videos of this channel, both media files and metadata.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Downloads Page
|
||||
Accessible at `/downloads/` of your Tube Archivist, this page handles all the download functionality.
|
||||
|
||||
|
||||
## Rescan Subscriptions
|
||||
The **Rescan Subscriptions** icon <img src="assets/icon-rescan.png?raw=true" alt="rescan icon" width="20px" style="margin:0 5px;"> will start a background task to look for new videos from the channels you are subscribed to. You can define the channel page size on the [settings page](Settings#subscriptions). With the default channel page size, expect this process to take around 2-3 seconds for each channel you are subscribed to. A status message will show the progress.
|
||||
|
||||
Then for every video found, **Tube Archivist** will skip the video if it has already been downloaded or if you added it to the *ignored* list before. All the other videos will get added to the download queue. Expect this to take around 1 second for each video as **Tube Archivist** needs to grab some additional metadata. New videos will get added at the bottom of the download queue.
|
||||
|
||||
## Download Queue
|
||||
The **Start Download** icon <img src="assets/icon-download.png?raw=true" alt="download icon" width="20px" style="margin:0 5px;"> will start the download process starting from the top of the queue. Take a look at the relevant settings on the [Settings Page](Settings#downloads). Once the process started, a progress message will show with additional details and controls:
|
||||
- The stop icon <img src="assets/icon-stop.png?raw=true" alt="stop icon" width="20px" style="margin:0 5px;"> will gracefully stop the download process, once the current video has been finished successfully.
|
||||
- The cancel icon <img src="assets/icon-close-red.png?raw=true" alt="close icon" width="20px" style="margin:0 5px;"> is equivalent to killing the process and will stop the download immediately. Any leftover files will get deleted, the canceled video will still be available in the download queue.
|
||||
|
||||
## Add to Download Queue
|
||||
The **Add to Download Queue** icon <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to manually add videos to the download queue. You have a few options:
|
||||
- Add a link to a YouTube video. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*.
|
||||
- Add a YouTube video ID. For example *2tdiKTSdE9Y*.
|
||||
- Add a link to a YouTube video by providing the shortened URL, for example *https://youtu.be/2tdiKTSdE9Y*.
|
||||
- Add a Channel ID or Channel URL to add every available video to the download queue. This will ignore the channel page size as described before and is meant for an initial download of the whole channel. You can still ignore selected videos before starting the download.
|
||||
- Add a playlist ID or URL to add every available video in the list to the download queue, for example *https://www.youtube.com/playlist?list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* or *PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha*. Note that when you add a link to a video in a playlist, Tube Archivist assumes you want to download only the specific video and not the whole playlist, for example *https://www.youtube.com/watch?v=CINVwWHlzTY&list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* will only add one video *CINVwWHlzTY* to the queue.
|
||||
- Add one link per line.
|
||||
- **Note**: Adding a link to a YouTube channel name is not yet supported, for example: *https://www.youtube.com/c/TomScottGo* will fail.
|
||||
|
||||
## The Download Queue
|
||||
Below the three buttons you find the download queue. New items will get added at the bottom of the queue, the next video to download once you click on **Start Download** will be the first in the list.
|
||||
|
||||
Every video in the download queue has two buttons:
|
||||
- **Ignore**: This will remove that video from the download queue and this video will not get added again, even when you **Rescan Subscriptions**.
|
||||
- **Download now**: This will give priority to this video. If the download process is already running, the prioritized video will get downloaded as soon as the current video is finished. If there is no download process running, this will start downloading this single video and stop after that.
|
||||
|
||||
You can flip the view by activating **Show Only Ignored Videos**. This will show all videos you have previously *ignored*.
|
||||
Every video in the ignored list has two buttons:
|
||||
- **Forget**: This will delete the item form the ignored list.
|
||||
- **Add to Queue**: This will add the ignored video back to the download queue.
|
||||
26
docs/Home.md
@@ -1,26 +0,0 @@
|
||||
# Tube Archivist Wiki
|
||||
Welcome to the official Tube Archivist Wiki. This is an up-to-date documentation of user functionality.
|
||||
|
||||
Table of contents:
|
||||
* [Main](Main): Tube Archivist landing page
|
||||
* [Channels](Channels): Browse your channels, handle subscriptions
|
||||
* [Downloads](Downloads): Scanning subscriptions, handle download queue
|
||||
* [Settings](Settings): All the configuration options
|
||||
|
||||
## Getting Started
|
||||
1. [Subscribe](Channels#channels-overview) to some of your favourite YouTube channels.
|
||||
2. [Scan](Downloads#rescan-subscriptions) subscriptions to add the latest videos to the download queue.
|
||||
3. [Add](Downloads#add-to-download-queue) additional videos, channels or playlist - ignore the ones you don't want to download.
|
||||
4. [Download](Downloads#download-queue) and let **Tube Archivist** do it's thing.
|
||||
5. Sit back and enjoy your archived and indexed collection!
|
||||
|
||||
## General Navigation
|
||||
* Clicking on the channel name or the channel icon brings you to the dedicated channel page to show videos from that channel.
|
||||
* Clicking on a video title brings you to the dedicated video page and shows additional details.
|
||||
* Clicking on a video thumbnail opens the video player and starts streaming the selected video.
|
||||
* Hover over the playing video to show additional control options.
|
||||
|
||||
|
||||
An empty checkbox icon <img src="assets/icon-unseen.png?raw=true" alt="unseen icon" width="20px" style="margin:0 5px;"> will show for videos you haven't marked as watched. Click on it and the icon will change to a filled checkbox <img src="assets/icon-seen.png?raw=true" alt="seen icon" width="20px" style="margin:0 5px;"> indicating it as watched - click again to revert.
|
||||
|
||||
When available the <img src="assets/icon-gridview.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> gridview icon will display the list in a grid, the <img src="assets/icon-listview.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> listview icon will arrange the items in a list.
|
||||
10
docs/Main.md
@@ -1,10 +0,0 @@
|
||||
# Tube Archivist Home Page Functionality
|
||||
|
||||
This is the landing page, when you first open **Tube Archivist**. You have a few options to sort and filter that view:
|
||||
- With the **Sort Order** you can select how the "Recent Videos" are sorted:
|
||||
- **Date Published**: Sorts the list by date when the video was published on YouTube, newest on top.
|
||||
- **Date Downloaded**: Sorts the list based on when you have downloaded the video to your archive, newest on top.
|
||||
- With **Hide Watched** you can filter out videos you have already marked as watched to only show unwatched videos.
|
||||
- You can use those two options together to for example filter the list to *Hide Watched* videos **and** sort by date downloaded.
|
||||
|
||||
Additionally the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;"> opens a text field to search your collection.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Settings Page
|
||||
Accessible at `/settings/` of your **Tube Archivist**, this page holds all the configurations and additional functionality related to the database.
|
||||
|
||||
Click on **Update Settings** at the bottom of the form to apply your configurations.
|
||||
|
||||
## Color scheme
|
||||
Switch between the easy on the eyes dark theme and the burning bright theme.
|
||||
|
||||
## Archive View
|
||||
- **Page Size**: Defines how many results get displayed on a given page. Same value goes for all archive views.
|
||||
|
||||
## Subscriptions
|
||||
Settings related to the channel management.
|
||||
- **Channel Page Size**: Defines how many pages will get analyzed by **Tube Archivist** each time you click on *Rescan Subscriptions*. The default page size used by yt-dlp is **50**, that's also the recommended value to set here. Any value higher will slow down the rescan process, for example if you set the value to 51, that means yt-dlp will have to go through 2 pages of results instead of 1 and by that doubling the time that process takes.
|
||||
|
||||
## Downloads
|
||||
Settings related to the download process.
|
||||
- **Download Limit**: Stop the download process after downloading the set quantity of videos.
|
||||
- **Download Speed Limit**: Set your download speed limit in KB/s. This will pass the option `--limit-rate` to yt-dlp.
|
||||
- **Sleep Interval**: Time in seconds to sleep between requests to YouTube. It's a good idea to set this to **3** seconds. Might be necessary to avoid throttling.
|
||||
|
||||
## Download Format
|
||||
Additional settings passed to yt-dlp.
|
||||
- **Format**: This controls which streams get downloaded and is equivalent to passing `--format` to yt-dlp. Use one of the recommended one or look at the documentation of [yt-dlp](https://github.com/yt-dlp/yt-dlp#format-selection). Please note: The option `--merge-output-format mp4` is automatically passed to yt-dlp to guarantee browser compatibility.
|
||||
- **Embed Metadata**: This saves the available tags directly into the media file by passing `--embed-metadata` to yt-dlp.
|
||||
- **Embed Thumbnail**: This will save the thumbnail into the media file by passing `--embed-thumbnail` to yt-dlp.
|
||||
|
||||
|
||||
# Actions
|
||||
Additional database functionality.
|
||||
|
||||
## Manual Media Files Import
|
||||
So far this depends on the video you are trying to import to be still available on YouTube to get the metadata. Add the files you like to import to the */cache/import* folder. Then start the process from the settings page *Manual Media Files Import*. Make sure to follow one of the two methods below.
|
||||
|
||||
### Method 1:
|
||||
Add a matching *.json* file with the media file. Both files need to have the same base name, for example:
|
||||
- For the media file: \<base-name>.mp4
|
||||
- For the JSON file: \<base-name>.info.json
|
||||
- Alternate JSON file: \<base-name>.json
|
||||
|
||||
**Tube Archivist** then looks for the 'id' key within the JSON file to identify the video.
|
||||
|
||||
### Method 2:
|
||||
Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like:
|
||||
- \<base-name>[\<youtube-id>].mp4
|
||||
- The YouTube ID in square brackets at the end of the filename is the crucial part.
|
||||
|
||||
### Some notes:
|
||||
- This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run.
|
||||
- Maybe start with a subset of your files to import to make sure everything goes well...
|
||||
- Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`.
|
||||
|
||||
## Backup Database
|
||||
This will backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files plus a complete export of the index in a set of conventional **json** files.
|
||||
|
||||
BE AWARE: This will **not** backup any media files, just the metadata from the Elasticsearch.
|
||||
|
||||
## Restore From Backup
|
||||
The restore functionality will expect the same zip file in *cache/backup* as created from the **Backup database** function. This will recreate the index from the snapshot. If there are multiple backup files in the folder, the newest one will take priority.
|
||||
|
||||
BE AWARE: This will **replace** your current index with the one from the backup file. This won't restore any media files.
|
||||
|
||||
## Rescan Filesystem
|
||||
This function will go through all your media files and looks at the whole index to try to find any issues:
|
||||
- Should the filename not match with the indexed media url, this will rename the video files correctly and update the index with the new link.
|
||||
- When you delete media files from the filesystem outside of the Tube Archivist interface, this will delete leftover metadata from the index.
|
||||
- When you have media files that are not indexed yet, this will grab the metadata from YouTube like it was a newly downloaded video. This can be useful when restoring from an older backup file with missing metadata but already downloaded mediafiles. NOTE: This only works if the media files are named in the same convention as Tube Archivist does, particularly the YouTube ID needs to be at the same index in the filename, alternatively see above for *Manual Media Files Import*.
|
||||
|
||||
BE AWARE: There is no undo.
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
18
nginx.conf
@@ -1,18 +0,0 @@
|
||||
server {
|
||||
|
||||
listen 8000;
|
||||
|
||||
location /cache/ {
|
||||
alias /cache/;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /youtube/;
|
||||
}
|
||||
|
||||
location / {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass localhost:8080;
|
||||
}
|
||||
|
||||
}
|
||||
1137
package-lock.json
generated
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint 'tubearchivist/static/**/*.js'",
|
||||
"format": "prettier --write 'tubearchivist/static/**/*.js'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.26.0",
|
||||
"prettier": "^2.7.1",
|
||||
"eslint-config-prettier": "^8.5.0"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
20
run.sh
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# startup script inside the container for tubearchivist
|
||||
|
||||
counter=0
|
||||
until curl "$ES_URL" -fs; do
|
||||
echo "waiting for elastic search to start"
|
||||
counter=$((counter+1))
|
||||
if [[ $counter -eq 12 ]]; then
|
||||
# fail after 1 min
|
||||
echo "failed to connect to elastic search, exiting..."
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --noinput -c
|
||||
nginx &
|
||||
celery -A home.tasks worker --loglevel=INFO &
|
||||
uwsgi --ini uwsgi.ini
|
||||
3
tubearchivist/api/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# TubeArchivist API
|
||||
|
||||
All API documentation has moved to [docs.tubearchivist.com](https://docs.tubearchivist.com/).
|
||||
0
tubearchivist/api/__init__.py
Normal file
3
tubearchivist/api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin # noqa: F401
|
||||
|
||||
# Register your models here.
|
||||
10
tubearchivist/api/apps.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""apps file for api package"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
"""app config"""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
0
tubearchivist/api/migrations/__init__.py
Normal file
3
tubearchivist/api/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""api models"""
|
||||
|
||||
# from django.db import models
|
||||
0
tubearchivist/api/serializers.py
Normal file
0
tubearchivist/api/src/__init__.py
Normal file
351
tubearchivist/api/src/aggs.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""aggregations"""
|
||||
|
||||
from home.src.es.connect import ElasticWrap
|
||||
from home.src.ta.helper import get_duration_str
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class AggBase:
|
||||
"""base class for aggregation calls"""
|
||||
|
||||
path: str = ""
|
||||
data: dict = {}
|
||||
name: str = ""
|
||||
|
||||
def get(self):
|
||||
"""make get call"""
|
||||
response, _ = ElasticWrap(self.path).get(self.data)
|
||||
print(f"[agg][{self.name}] took {response.get('took')} ms to process")
|
||||
|
||||
return response.get("aggregations")
|
||||
|
||||
def process(self):
|
||||
"""implement in subclassess"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Video(AggBase):
|
||||
"""get video stats"""
|
||||
|
||||
name = "video_stats"
|
||||
path = "ta_video/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"video_type": {
|
||||
"terms": {"field": "vid_type"},
|
||||
"aggs": {
|
||||
"media_size": {"sum": {"field": "media_size"}},
|
||||
"duration": {"sum": {"field": "player.duration"}},
|
||||
},
|
||||
},
|
||||
"video_active": {
|
||||
"terms": {"field": "active"},
|
||||
"aggs": {
|
||||
"media_size": {"sum": {"field": "media_size"}},
|
||||
"duration": {"sum": {"field": "player.duration"}},
|
||||
},
|
||||
},
|
||||
"video_media_size": {"sum": {"field": "media_size"}},
|
||||
"video_count": {"value_count": {"field": "youtube_id"}},
|
||||
"duration": {"sum": {"field": "player.duration"}},
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""process aggregation"""
|
||||
aggregations = self.get()
|
||||
|
||||
duration = int(aggregations["duration"]["value"])
|
||||
response = {
|
||||
"doc_count": aggregations["video_count"]["value"],
|
||||
"media_size": int(aggregations["video_media_size"]["value"]),
|
||||
"duration": duration,
|
||||
"duration_str": get_duration_str(duration),
|
||||
}
|
||||
for bucket in aggregations["video_type"]["buckets"]:
|
||||
duration = int(bucket["duration"].get("value"))
|
||||
response.update(
|
||||
{
|
||||
f"type_{bucket['key']}": {
|
||||
"doc_count": bucket.get("doc_count"),
|
||||
"media_size": int(bucket["media_size"].get("value")),
|
||||
"duration": duration,
|
||||
"duration_str": get_duration_str(duration),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
for bucket in aggregations["video_active"]["buckets"]:
|
||||
duration = int(bucket["duration"].get("value"))
|
||||
response.update(
|
||||
{
|
||||
f"active_{bucket['key_as_string']}": {
|
||||
"doc_count": bucket.get("doc_count"),
|
||||
"media_size": int(bucket["media_size"].get("value")),
|
||||
"duration": duration,
|
||||
"duration_str": get_duration_str(duration),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class Channel(AggBase):
|
||||
"""get channel stats"""
|
||||
|
||||
name = "channel_stats"
|
||||
path = "ta_channel/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"channel_count": {"value_count": {"field": "channel_id"}},
|
||||
"channel_active": {"terms": {"field": "channel_active"}},
|
||||
"channel_subscribed": {"terms": {"field": "channel_subscribed"}},
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""process aggregation"""
|
||||
aggregations = self.get()
|
||||
|
||||
response = {
|
||||
"doc_count": aggregations["channel_count"].get("value"),
|
||||
}
|
||||
for bucket in aggregations["channel_active"]["buckets"]:
|
||||
key = f"active_{bucket['key_as_string']}"
|
||||
response.update({key: bucket.get("doc_count")})
|
||||
for bucket in aggregations["channel_subscribed"]["buckets"]:
|
||||
key = f"subscribed_{bucket['key_as_string']}"
|
||||
response.update({key: bucket.get("doc_count")})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class Playlist(AggBase):
|
||||
"""get playlist stats"""
|
||||
|
||||
name = "playlist_stats"
|
||||
path = "ta_playlist/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"playlist_count": {"value_count": {"field": "playlist_id"}},
|
||||
"playlist_active": {"terms": {"field": "playlist_active"}},
|
||||
"playlist_subscribed": {"terms": {"field": "playlist_subscribed"}},
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""process aggregation"""
|
||||
aggregations = self.get()
|
||||
response = {"doc_count": aggregations["playlist_count"].get("value")}
|
||||
for bucket in aggregations["playlist_active"]["buckets"]:
|
||||
key = f"active_{bucket['key_as_string']}"
|
||||
response.update({key: bucket.get("doc_count")})
|
||||
for bucket in aggregations["playlist_subscribed"]["buckets"]:
|
||||
key = f"subscribed_{bucket['key_as_string']}"
|
||||
response.update({key: bucket.get("doc_count")})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class Download(AggBase):
|
||||
"""get downloads queue stats"""
|
||||
|
||||
name = "download_queue_stats"
|
||||
path = "ta_download/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"status": {"terms": {"field": "status"}},
|
||||
"video_type": {
|
||||
"filter": {"term": {"status": "pending"}},
|
||||
"aggs": {"type_pending": {"terms": {"field": "vid_type"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""process aggregation"""
|
||||
aggregations = self.get()
|
||||
response = {}
|
||||
for bucket in aggregations["status"]["buckets"]:
|
||||
response.update({bucket["key"]: bucket.get("doc_count")})
|
||||
|
||||
for bucket in aggregations["video_type"]["type_pending"]["buckets"]:
|
||||
key = f"pending_{bucket['key']}"
|
||||
response.update({key: bucket.get("doc_count")})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WatchProgress(AggBase):
|
||||
"""get watch progress"""
|
||||
|
||||
name = "watch_progress"
|
||||
path = "ta_video/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
name: {
|
||||
"terms": {"field": "player.watched"},
|
||||
"aggs": {
|
||||
"watch_docs": {
|
||||
"filter": {"terms": {"player.watched": [True, False]}},
|
||||
"aggs": {
|
||||
"true_count": {"value_count": {"field": "_index"}},
|
||||
"duration": {"sum": {"field": "player.duration"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total_duration": {"sum": {"field": "player.duration"}},
|
||||
"total_vids": {"value_count": {"field": "_index"}},
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""make the call"""
|
||||
aggregations = self.get()
|
||||
buckets = aggregations[self.name]["buckets"]
|
||||
|
||||
response = {}
|
||||
all_duration = int(aggregations["total_duration"].get("value"))
|
||||
response.update(
|
||||
{
|
||||
"total": {
|
||||
"duration": all_duration,
|
||||
"duration_str": get_duration_str(all_duration),
|
||||
"items": aggregations["total_vids"].get("value"),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
for bucket in buckets:
|
||||
response.update(self._build_bucket(bucket, all_duration))
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _build_bucket(bucket, all_duration):
|
||||
"""parse bucket"""
|
||||
|
||||
duration = int(bucket["watch_docs"]["duration"]["value"])
|
||||
duration_str = get_duration_str(duration)
|
||||
items = bucket["watch_docs"]["true_count"]["value"]
|
||||
if bucket["key_as_string"] == "false":
|
||||
key = "unwatched"
|
||||
else:
|
||||
key = "watched"
|
||||
|
||||
bucket_parsed = {
|
||||
key: {
|
||||
"duration": duration,
|
||||
"duration_str": duration_str,
|
||||
"progress": duration / all_duration if all_duration else 0,
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
|
||||
return bucket_parsed
|
||||
|
||||
|
||||
class DownloadHist(AggBase):
|
||||
"""get downloads histogram last week"""
|
||||
|
||||
name = "videos_last_week"
|
||||
path = "ta_video/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
name: {
|
||||
"date_histogram": {
|
||||
"field": "date_downloaded",
|
||||
"calendar_interval": "day",
|
||||
"format": "yyyy-MM-dd",
|
||||
"order": {"_key": "desc"},
|
||||
"time_zone": EnvironmentSettings.TZ,
|
||||
},
|
||||
"aggs": {
|
||||
"total_videos": {"value_count": {"field": "youtube_id"}},
|
||||
"media_size": {"sum": {"field": "media_size"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
"query": {
|
||||
"range": {
|
||||
"date_downloaded": {
|
||||
"gte": "now-7d/d",
|
||||
"time_zone": EnvironmentSettings.TZ,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def process(self):
|
||||
"""process query"""
|
||||
aggregations = self.get()
|
||||
buckets = aggregations[self.name]["buckets"]
|
||||
|
||||
response = [
|
||||
{
|
||||
"date": i.get("key_as_string"),
|
||||
"count": i.get("doc_count"),
|
||||
"media_size": i["media_size"].get("value"),
|
||||
}
|
||||
for i in buckets
|
||||
]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BiggestChannel(AggBase):
|
||||
"""get channel aggregations"""
|
||||
|
||||
def __init__(self, order):
|
||||
self.data["aggs"][self.name]["multi_terms"]["order"] = {order: "desc"}
|
||||
|
||||
name = "channel_stats"
|
||||
path = "ta_video/_search"
|
||||
data = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
name: {
|
||||
"multi_terms": {
|
||||
"terms": [
|
||||
{"field": "channel.channel_name.keyword"},
|
||||
{"field": "channel.channel_id"},
|
||||
],
|
||||
"order": {"doc_count": "desc"},
|
||||
},
|
||||
"aggs": {
|
||||
"doc_count": {"value_count": {"field": "_index"}},
|
||||
"duration": {"sum": {"field": "player.duration"}},
|
||||
"media_size": {"sum": {"field": "media_size"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
order_choices = ["doc_count", "duration", "media_size"]
|
||||
|
||||
def process(self):
|
||||
"""process aggregation, order_by validated in the view"""
|
||||
|
||||
aggregations = self.get()
|
||||
buckets = aggregations[self.name]["buckets"]
|
||||
|
||||
response = [
|
||||
{
|
||||
"id": i["key"][1],
|
||||
"name": i["key"][0].title(),
|
||||
"doc_count": i["doc_count"]["value"],
|
||||
"duration": i["duration"]["value"],
|
||||
"duration_str": get_duration_str(int(i["duration"]["value"])),
|
||||
"media_size": i["media_size"]["value"],
|
||||
}
|
||||
for i in buckets
|
||||
]
|
||||
|
||||
return response
|
||||
176
tubearchivist/api/src/search_processor.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Functionality:
|
||||
- processing search results for frontend
|
||||
- this is duplicated code from home.src.frontend.searching.SearchHandler
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from home.src.download.thumbnails import ThumbManager
|
||||
from home.src.ta.helper import date_praser, get_duration_str
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class SearchProcess:
|
||||
"""process search results"""
|
||||
|
||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.processed = False
|
||||
|
||||
def process(self):
|
||||
"""detect type and process"""
|
||||
if "_source" in self.response.keys():
|
||||
# single
|
||||
self.processed = self._process_result(self.response)
|
||||
|
||||
elif "hits" in self.response.keys():
|
||||
# multiple
|
||||
self.processed = []
|
||||
all_sources = self.response["hits"]["hits"]
|
||||
for result in all_sources:
|
||||
self.processed.append(self._process_result(result))
|
||||
|
||||
return self.processed
|
||||
|
||||
def _process_result(self, result):
|
||||
"""detect which type of data to process"""
|
||||
index = result["_index"]
|
||||
processed = False
|
||||
if index == "ta_video":
|
||||
processed = self._process_video(result["_source"])
|
||||
if index == "ta_channel":
|
||||
processed = self._process_channel(result["_source"])
|
||||
if index == "ta_playlist":
|
||||
processed = self._process_playlist(result["_source"])
|
||||
if index == "ta_download":
|
||||
processed = self._process_download(result["_source"])
|
||||
if index == "ta_comment":
|
||||
processed = self._process_comment(result["_source"])
|
||||
if index == "ta_subtitle":
|
||||
processed = self._process_subtitle(result)
|
||||
|
||||
if isinstance(processed, dict):
|
||||
processed.update(
|
||||
{
|
||||
"_index": index,
|
||||
"_score": round(result.get("_score") or 0, 2),
|
||||
}
|
||||
)
|
||||
|
||||
return processed
|
||||
|
||||
@staticmethod
|
||||
def _process_channel(channel_dict):
|
||||
"""run on single channel"""
|
||||
channel_id = channel_dict["channel_id"]
|
||||
art_base = f"/cache/channels/{channel_id}"
|
||||
date_str = date_praser(channel_dict["channel_last_refresh"])
|
||||
channel_dict.update(
|
||||
{
|
||||
"channel_last_refresh": date_str,
|
||||
"channel_banner_url": f"{art_base}_banner.jpg",
|
||||
"channel_thumb_url": f"{art_base}_thumb.jpg",
|
||||
"channel_tvart_url": f"{art_base}_tvart.jpg",
|
||||
}
|
||||
)
|
||||
|
||||
return dict(sorted(channel_dict.items()))
|
||||
|
||||
def _process_video(self, video_dict):
|
||||
"""run on single video dict"""
|
||||
video_id = video_dict["youtube_id"]
|
||||
media_url = urllib.parse.quote(video_dict["media_url"])
|
||||
vid_last_refresh = date_praser(video_dict["vid_last_refresh"])
|
||||
published = date_praser(video_dict["published"])
|
||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
||||
channel = self._process_channel(video_dict["channel"])
|
||||
|
||||
if "subtitles" in video_dict:
|
||||
for idx, _ in enumerate(video_dict["subtitles"]):
|
||||
url = video_dict["subtitles"][idx]["media_url"]
|
||||
video_dict["subtitles"][idx]["media_url"] = f"/media/{url}"
|
||||
|
||||
video_dict.update(
|
||||
{
|
||||
"channel": channel,
|
||||
"media_url": f"/media/{media_url}",
|
||||
"vid_last_refresh": vid_last_refresh,
|
||||
"published": published,
|
||||
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
|
||||
}
|
||||
)
|
||||
|
||||
return dict(sorted(video_dict.items()))
|
||||
|
||||
@staticmethod
|
||||
def _process_playlist(playlist_dict):
|
||||
"""run on single playlist dict"""
|
||||
playlist_id = playlist_dict["playlist_id"]
|
||||
playlist_last_refresh = date_praser(
|
||||
playlist_dict["playlist_last_refresh"]
|
||||
)
|
||||
playlist_dict.update(
|
||||
{
|
||||
"playlist_thumbnail": f"/cache/playlists/{playlist_id}.jpg",
|
||||
"playlist_last_refresh": playlist_last_refresh,
|
||||
}
|
||||
)
|
||||
|
||||
return dict(sorted(playlist_dict.items()))
|
||||
|
||||
def _process_download(self, download_dict):
|
||||
"""run on single download item"""
|
||||
video_id = download_dict["youtube_id"]
|
||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
||||
published = date_praser(download_dict["published"])
|
||||
|
||||
download_dict.update(
|
||||
{
|
||||
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
|
||||
"published": published,
|
||||
}
|
||||
)
|
||||
return dict(sorted(download_dict.items()))
|
||||
|
||||
def _process_comment(self, comment_dict):
|
||||
"""run on all comments, create reply thread"""
|
||||
all_comments = comment_dict["comment_comments"]
|
||||
processed_comments = []
|
||||
|
||||
for comment in all_comments:
|
||||
if comment["comment_parent"] == "root":
|
||||
comment.update({"comment_replies": []})
|
||||
processed_comments.append(comment)
|
||||
else:
|
||||
processed_comments[-1]["comment_replies"].append(comment)
|
||||
|
||||
return processed_comments
|
||||
|
||||
def _process_subtitle(self, result):
|
||||
"""take complete result dict to extract highlight"""
|
||||
subtitle_dict = result["_source"]
|
||||
highlight = result.get("highlight")
|
||||
if highlight:
|
||||
# replace lines with the highlighted markdown
|
||||
subtitle_line = highlight.get("subtitle_line")[0]
|
||||
subtitle_dict.update({"subtitle_line": subtitle_line})
|
||||
|
||||
thumb_path = ThumbManager(subtitle_dict["youtube_id"]).vid_thumb_path()
|
||||
subtitle_dict.update({"vid_thumb_url": f"/cache/{thumb_path}"})
|
||||
|
||||
return subtitle_dict
|
||||
|
||||
|
||||
def process_aggs(response):
|
||||
"""convert aggs duration to str"""
|
||||
|
||||
if response.get("aggregations"):
|
||||
aggs = response["aggregations"]
|
||||
if "total_duration" in aggs:
|
||||
duration_sec = int(aggs["total_duration"]["value"])
|
||||
aggs["total_duration"].update(
|
||||
{"value_str": get_duration_str(duration_sec)}
|
||||
)
|
||||
3
tubearchivist/api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase # noqa: F401
|
||||
|
||||
# Create your tests here.
|
||||
189
tubearchivist/api/urls.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""all api urls"""
|
||||
|
||||
from api import views
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path("ping/", views.PingView.as_view(), name="ping"),
|
||||
path("login/", views.LoginApiView.as_view(), name="api-login"),
|
||||
path(
|
||||
"video/",
|
||||
views.VideoApiListView.as_view(),
|
||||
name="api-video-list",
|
||||
),
|
||||
path(
|
||||
"video/<slug:video_id>/",
|
||||
views.VideoApiView.as_view(),
|
||||
name="api-video",
|
||||
),
|
||||
path(
|
||||
"video/<slug:video_id>/progress/",
|
||||
views.VideoProgressView.as_view(),
|
||||
name="api-video-progress",
|
||||
),
|
||||
path(
|
||||
"video/<slug:video_id>/comment/",
|
||||
views.VideoCommentView.as_view(),
|
||||
name="api-video-comment",
|
||||
),
|
||||
path(
|
||||
"video/<slug:video_id>/similar/",
|
||||
views.VideoSimilarView.as_view(),
|
||||
name="api-video-similar",
|
||||
),
|
||||
path(
|
||||
"video/<slug:video_id>/sponsor/",
|
||||
views.VideoSponsorView.as_view(),
|
||||
name="api-video-sponsor",
|
||||
),
|
||||
path(
|
||||
"channel/",
|
||||
views.ChannelApiListView.as_view(),
|
||||
name="api-channel-list",
|
||||
),
|
||||
path(
|
||||
"channel/search/",
|
||||
views.ChannelApiSearchView.as_view(),
|
||||
name="api-channel-search",
|
||||
),
|
||||
path(
|
||||
"channel/<slug:channel_id>/",
|
||||
views.ChannelApiView.as_view(),
|
||||
name="api-channel",
|
||||
),
|
||||
path(
|
||||
"channel/<slug:channel_id>/video/",
|
||||
views.ChannelApiVideoView.as_view(),
|
||||
name="api-channel-video",
|
||||
),
|
||||
path(
|
||||
"playlist/",
|
||||
views.PlaylistApiListView.as_view(),
|
||||
name="api-playlist-list",
|
||||
),
|
||||
path(
|
||||
"playlist/<slug:playlist_id>/",
|
||||
views.PlaylistApiView.as_view(),
|
||||
name="api-playlist",
|
||||
),
|
||||
path(
|
||||
"playlist/<slug:playlist_id>/video/",
|
||||
views.PlaylistApiVideoView.as_view(),
|
||||
name="api-playlist-video",
|
||||
),
|
||||
path(
|
||||
"download/",
|
||||
views.DownloadApiListView.as_view(),
|
||||
name="api-download-list",
|
||||
),
|
||||
path(
|
||||
"download/<slug:video_id>/",
|
||||
views.DownloadApiView.as_view(),
|
||||
name="api-download",
|
||||
),
|
||||
path(
|
||||
"refresh/",
|
||||
views.RefreshView.as_view(),
|
||||
name="api-refresh",
|
||||
),
|
||||
path(
|
||||
"snapshot/",
|
||||
views.SnapshotApiListView.as_view(),
|
||||
name="api-snapshot-list",
|
||||
),
|
||||
path(
|
||||
"snapshot/<slug:snapshot_id>/",
|
||||
views.SnapshotApiView.as_view(),
|
||||
name="api-snapshot",
|
||||
),
|
||||
path(
|
||||
"backup/",
|
||||
views.BackupApiListView.as_view(),
|
||||
name="api-backup-list",
|
||||
),
|
||||
path(
|
||||
"backup/<str:filename>/",
|
||||
views.BackupApiView.as_view(),
|
||||
name="api-backup",
|
||||
),
|
||||
path(
|
||||
"task-name/",
|
||||
views.TaskListView.as_view(),
|
||||
name="api-task-list",
|
||||
),
|
||||
path(
|
||||
"task-name/<slug:task_name>/",
|
||||
views.TaskNameListView.as_view(),
|
||||
name="api-task-name-list",
|
||||
),
|
||||
path(
|
||||
"task-id/<slug:task_id>/",
|
||||
views.TaskIDView.as_view(),
|
||||
name="api-task-id",
|
||||
),
|
||||
path(
|
||||
"config/user/",
|
||||
views.UserConfigView.as_view(),
|
||||
name="api-config-user",
|
||||
),
|
||||
path(
|
||||
"cookie/",
|
||||
views.CookieView.as_view(),
|
||||
name="api-cookie",
|
||||
),
|
||||
path(
|
||||
"watched/",
|
||||
views.WatchedView.as_view(),
|
||||
name="api-watched",
|
||||
),
|
||||
path(
|
||||
"search/",
|
||||
views.SearchView.as_view(),
|
||||
name="api-search",
|
||||
),
|
||||
path(
|
||||
"token/",
|
||||
views.TokenView.as_view(),
|
||||
name="api-token",
|
||||
),
|
||||
path(
|
||||
"notification/",
|
||||
views.NotificationView.as_view(),
|
||||
name="api-notification",
|
||||
),
|
||||
path(
|
||||
"stats/video/",
|
||||
views.StatVideoView.as_view(),
|
||||
name="api-stats-video",
|
||||
),
|
||||
path(
|
||||
"stats/channel/",
|
||||
views.StatChannelView.as_view(),
|
||||
name="api-stats-channel",
|
||||
),
|
||||
path(
|
||||
"stats/playlist/",
|
||||
views.StatPlaylistView.as_view(),
|
||||
name="api-stats-playlist",
|
||||
),
|
||||
path(
|
||||
"stats/download/",
|
||||
views.StatDownloadView.as_view(),
|
||||
name="api-stats-download",
|
||||
),
|
||||
path(
|
||||
"stats/watch/",
|
||||
views.StatWatchProgress.as_view(),
|
||||
name="api-stats-watch",
|
||||
),
|
||||
path(
|
||||
"stats/downloadhist/",
|
||||
views.StatDownloadHist.as_view(),
|
||||
name="api-stats-downloadhist",
|
||||
),
|
||||
path(
|
||||
"stats/biggestchannels/",
|
||||
views.StatBiggestChannel.as_view(),
|
||||
name="api-stats-biggestchannels",
|
||||
),
|
||||
]
|
||||
1240
tubearchivist/api/views.py
Normal file
0
tubearchivist/config/management/__init__.py
Normal file
163
tubearchivist/config/management/commands/ta_connection.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Functionality:
|
||||
- check that all connections are working
|
||||
"""
|
||||
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from home.src.es.connect import ElasticWrap
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
from home.src.ta.ta_redis import RedisArchivist
|
||||
|
||||
TOPIC = """
|
||||
|
||||
#######################
|
||||
# Connection check #
|
||||
#######################
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""command framework"""
|
||||
|
||||
TIMEOUT = 120
|
||||
MIN_MAJOR, MAX_MAJOR = 8, 8
|
||||
MIN_MINOR = 0
|
||||
|
||||
# pylint: disable=no-member
|
||||
help = "Check connections"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""run all commands"""
|
||||
self.stdout.write(TOPIC)
|
||||
self._redis_connection_check()
|
||||
self._redis_config_set()
|
||||
self._es_connection_check()
|
||||
self._es_version_check()
|
||||
self._es_path_check()
|
||||
|
||||
def _redis_connection_check(self):
|
||||
"""check ir redis connection is established"""
|
||||
self.stdout.write("[1] connect to Redis")
|
||||
redis_conn = RedisArchivist().conn
|
||||
for _ in range(5):
|
||||
try:
|
||||
pong = redis_conn.execute_command("PING")
|
||||
if pong:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✓ Redis connection verified")
|
||||
)
|
||||
return
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.stdout.write(" ... retry Redis connection")
|
||||
sleep(2)
|
||||
|
||||
message = " 🗙 Redis connection failed"
|
||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||
RedisArchivist().exec("PING")
|
||||
sleep(60)
|
||||
raise CommandError(message)
|
||||
|
||||
def _redis_config_set(self):
|
||||
"""set config for redis if not set already"""
|
||||
self.stdout.write("[2] set Redis config")
|
||||
redis_conn = RedisArchivist().conn
|
||||
timeout_is = int(redis_conn.config_get("timeout").get("timeout"))
|
||||
if not timeout_is:
|
||||
redis_conn.config_set("timeout", 3600)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(" ✓ Redis config set"))
|
||||
|
||||
def _es_connection_check(self):
|
||||
"""wait for elasticsearch connection"""
|
||||
self.stdout.write("[3] connect to Elastic Search")
|
||||
total = self.TIMEOUT // 5
|
||||
for i in range(total):
|
||||
self.stdout.write(f" ... waiting for ES [{i}/{total}]")
|
||||
try:
|
||||
_, status_code = ElasticWrap("/").get(
|
||||
timeout=1, print_error=False
|
||||
)
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
):
|
||||
sleep(5)
|
||||
continue
|
||||
|
||||
if status_code and status_code == 200:
|
||||
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
|
||||
_, _ = ElasticWrap(path).get(timeout=60)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✓ ES connection established")
|
||||
)
|
||||
return
|
||||
|
||||
response, status_code = ElasticWrap("/").get(
|
||||
timeout=1, print_error=False
|
||||
)
|
||||
|
||||
message = " 🗙 ES connection failed"
|
||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||
self.stdout.write(f" error message: {response}")
|
||||
self.stdout.write(f" status code: {status_code}")
|
||||
sleep(60)
|
||||
raise CommandError(message)
|
||||
|
||||
def _es_version_check(self):
|
||||
"""check for minimal elasticsearch version"""
|
||||
self.stdout.write("[4] Elastic Search version check")
|
||||
response, _ = ElasticWrap("/").get()
|
||||
version = response["version"]["number"]
|
||||
major = int(version.split(".")[0])
|
||||
|
||||
if self.MIN_MAJOR <= major <= self.MAX_MAJOR:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✓ ES version check passed")
|
||||
)
|
||||
return
|
||||
|
||||
message = (
|
||||
" 🗙 ES version check failed. "
|
||||
+ f"Expected {self.MIN_MAJOR}.{self.MIN_MINOR} but got {version}"
|
||||
)
|
||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||
sleep(60)
|
||||
raise CommandError(message)
|
||||
|
||||
def _es_path_check(self):
|
||||
"""check that path.repo var is set"""
|
||||
self.stdout.write("[5] check ES path.repo env var")
|
||||
response, _ = ElasticWrap("_nodes/_all/settings").get()
|
||||
snaphost_roles = [
|
||||
"data",
|
||||
"data_cold",
|
||||
"data_content",
|
||||
"data_frozen",
|
||||
"data_hot",
|
||||
"data_warm",
|
||||
"master",
|
||||
]
|
||||
for node in response["nodes"].values():
|
||||
if not (set(node["roles"]) & set(snaphost_roles)):
|
||||
continue
|
||||
|
||||
if node["settings"]["path"].get("repo"):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✓ path.repo env var is set")
|
||||
)
|
||||
return
|
||||
|
||||
message = (
|
||||
" 🗙 path.repo env var not found. "
|
||||
+ "set the following env var to the ES container:\n"
|
||||
+ " path.repo="
|
||||
+ EnvironmentSettings.ES_SNAPSHOT_DIR
|
||||
)
|
||||
self.stdout.write(self.style.ERROR(message))
|
||||
sleep(60)
|
||||
raise CommandError(message)
|
||||
193
tubearchivist/config/management/commands/ta_envcheck.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Functionality:
|
||||
- Check environment at startup
|
||||
- Process config file overwrites from env var
|
||||
- Stop startup on error
|
||||
- python management.py ta_envcheck
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from home.models import Account
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
LOGO = """
|
||||
|
||||
.... .....
|
||||
...'',;:cc,. .;::;;,'...
|
||||
..,;:cccllclc, .:ccllllcc;,..
|
||||
..,:cllcc:;,'.',. ....'',;ccllc:,..
|
||||
..;cllc:,'.. ...,:cccc:'.
|
||||
.;cccc;.. ..,:ccc:'.
|
||||
.ckkkOkxollllllllllllc. .,:::;. .,cclc;
|
||||
.:0MMMMMMMMMMMMMMMMMMMX: .cNMMMWx. .;clc:
|
||||
.;lOXK0000KNMMMMX00000KO; ;KMMMMMNl. .;ccl:,.
|
||||
.;:c:'.....kMMMNo........ 'OMMMWMMMK: '::;;'.
|
||||
....... .xMMMNl .dWMMXdOMMMO' ........
|
||||
.:cc:;. .xMMMNc .lNMMNo.:XMMWx. .:cl:.
|
||||
.:llc,. .:xxxd, ;KMMMk. .oWMMNl. .:llc'
|
||||
.cll:. .;:;;:::,. 'OMMMK:';''kWMMK: .;llc,
|
||||
.cll:. .,;;;;;;,. .,xWMMNl.:l:.;KMMMO' .;llc'
|
||||
.:llc. .cOOOk; .lKNMMWx..:l:..lNMMWx. .:llc'
|
||||
.;lcc,. .xMMMNc :KMMMM0, .:lc. .xWMMNl.'ccl:.
|
||||
.cllc. .xMMMNc 'OMMMMXc...:lc...,0MMMKl:lcc,.
|
||||
.,ccl:. .xMMMNc .xWMMMWo.,;;:lc;;;.cXMMMXdcc;.
|
||||
.,clc:. .xMMMNc .lNMMMWk. .':clc:,. .dWMMW0o;.
|
||||
.,clcc,. .ckkkx; .okkkOx, .';,. 'kKKK0l.
|
||||
.':lcc:'..... . .. ..,;cllc,.
|
||||
.,cclc,.... ....;clc;..
|
||||
..,:,..,c:'.. ...';:,..,:,.
|
||||
....:lcccc:;,'''.....'',;;:clllc,....
|
||||
.'',;:cllllllccccclllllcc:,'..
|
||||
...'',,;;;;;;;;;,''...
|
||||
.....
|
||||
|
||||
"""
|
||||
|
||||
TOPIC = """
|
||||
#######################
|
||||
# Environment Setup #
|
||||
#######################
|
||||
|
||||
"""
|
||||
|
||||
EXPECTED_ENV_VARS = [
|
||||
"TA_USERNAME",
|
||||
"TA_PASSWORD",
|
||||
"ELASTIC_PASSWORD",
|
||||
"ES_URL",
|
||||
"TA_HOST",
|
||||
]
|
||||
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
|
||||
NGINX = "/etc/nginx/sites-available/default"
|
||||
UWSGI = "/app/uwsgi.ini"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""command framework"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
help = "Check environment before startup"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""run all commands"""
|
||||
self.stdout.write(LOGO)
|
||||
self.stdout.write(TOPIC)
|
||||
self._expected_vars()
|
||||
self._elastic_user_overwrite()
|
||||
self._ta_port_overwrite()
|
||||
self._ta_uwsgi_overwrite()
|
||||
self._enable_cast_overwrite()
|
||||
self._create_superuser()
|
||||
|
||||
def _expected_vars(self):
|
||||
"""check if expected env vars are set"""
|
||||
self.stdout.write("[1] checking expected env vars")
|
||||
env = os.environ
|
||||
for var in EXPECTED_ENV_VARS:
|
||||
if not env.get(var):
|
||||
message = f" 🗙 expected env var {var} not set\n {INST}"
|
||||
self.stdout.write(self.style.ERROR(message))
|
||||
raise CommandError(message)
|
||||
|
||||
message = " ✓ all expected env vars are set"
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
|
||||
def _elastic_user_overwrite(self):
|
||||
"""check for ELASTIC_USER overwrite"""
|
||||
self.stdout.write("[2] check ES user overwrite")
|
||||
env = EnvironmentSettings.ES_USER
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
|
||||
|
||||
def _ta_port_overwrite(self):
|
||||
"""set TA_PORT overwrite for nginx"""
|
||||
self.stdout.write("[3] check TA_PORT overwrite")
|
||||
overwrite = EnvironmentSettings.TA_PORT
|
||||
if not overwrite:
|
||||
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
|
||||
return
|
||||
|
||||
regex = re.compile(r"listen [0-9]{1,5}")
|
||||
to_overwrite = f"listen {overwrite}"
|
||||
changed = file_overwrite(NGINX, regex, to_overwrite)
|
||||
if changed:
|
||||
message = f" ✓ TA_PORT changed to {overwrite}"
|
||||
else:
|
||||
message = f" ✓ TA_PORT already set to {overwrite}"
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
|
||||
def _ta_uwsgi_overwrite(self):
|
||||
"""set TA_UWSGI_PORT overwrite"""
|
||||
self.stdout.write("[4] check TA_UWSGI_PORT overwrite")
|
||||
overwrite = EnvironmentSettings.TA_UWSGI_PORT
|
||||
if not overwrite:
|
||||
message = " TA_UWSGI_PORT is not set"
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
return
|
||||
|
||||
# nginx
|
||||
regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}")
|
||||
to_overwrite = f"uwsgi_pass localhost:{overwrite}"
|
||||
changed = file_overwrite(NGINX, regex, to_overwrite)
|
||||
|
||||
# uwsgi
|
||||
regex = re.compile(r"socket = :[0-9]{1,5}")
|
||||
to_overwrite = f"socket = :{overwrite}"
|
||||
changed = file_overwrite(UWSGI, regex, to_overwrite)
|
||||
|
||||
if changed:
|
||||
message = f" ✓ TA_UWSGI_PORT changed to {overwrite}"
|
||||
else:
|
||||
message = f" ✓ TA_UWSGI_PORT already set to {overwrite}"
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
|
||||
def _enable_cast_overwrite(self):
|
||||
"""cast workaround, remove auth for static files in nginx"""
|
||||
self.stdout.write("[5] check ENABLE_CAST overwrite")
|
||||
overwrite = EnvironmentSettings.ENABLE_CAST
|
||||
if not overwrite:
|
||||
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
|
||||
return
|
||||
|
||||
regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n")
|
||||
changed = file_overwrite(NGINX, regex, "")
|
||||
if changed:
|
||||
message = " ✓ process nginx to enable Cast"
|
||||
else:
|
||||
message = " ✓ Cast is already enabled in nginx"
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
|
||||
def _create_superuser(self):
|
||||
"""create superuser if not exist"""
|
||||
self.stdout.write("[6] create superuser")
|
||||
is_created = Account.objects.filter(is_superuser=True)
|
||||
if is_created:
|
||||
message = " superuser already created"
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
return
|
||||
|
||||
name = EnvironmentSettings.TA_USERNAME
|
||||
password = EnvironmentSettings.TA_PASSWORD
|
||||
Account.objects.create_superuser(name, password)
|
||||
message = f" ✓ new superuser with name {name} created"
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
|
||||
|
||||
def file_overwrite(file_path, regex, overwrite):
|
||||
"""change file content from old to overwrite, return true when changed"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
file_content = f.read()
|
||||
|
||||
changed = re.sub(regex, overwrite, file_content)
|
||||
if changed == file_content:
|
||||
return False
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(changed)
|
||||
|
||||
return True
|
||||
181
tubearchivist/config/management/commands/ta_migpath.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""filepath migration from v0.3.6 to v0.3.7"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||
from home.src.ta.helper import ignore_filelist
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
TOPIC = """
|
||||
|
||||
########################
|
||||
# Filesystem Migration #
|
||||
########################
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""command framework"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""run commands"""
|
||||
self.stdout.write(TOPIC)
|
||||
|
||||
handler = FolderMigration()
|
||||
to_migrate = handler.get_to_migrate()
|
||||
if not to_migrate:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" no channel migration needed\n")
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(" migrating channels"))
|
||||
total_channels = handler.create_folders(to_migrate)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" created {total_channels} channels")
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" migrating {len(to_migrate)} videos")
|
||||
)
|
||||
handler.migrate_videos(to_migrate)
|
||||
self.stdout.write(self.style.SUCCESS(" update videos in index"))
|
||||
handler.send_bulk()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(" cleanup old folders"))
|
||||
handler.delete_old()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(" ✓ migration completed\n"))
|
||||
|
||||
|
||||
class FolderMigration:
|
||||
"""migrate video archive folder"""
|
||||
|
||||
def __init__(self):
|
||||
self.videos = EnvironmentSettings.MEDIA_DIR
|
||||
self.bulk_list = []
|
||||
|
||||
def get_to_migrate(self):
|
||||
"""get videos to migrate"""
|
||||
script = (
|
||||
"doc['media_url'].value == "
|
||||
+ "doc['channel.channel_id'].value + '/'"
|
||||
+ " + doc['youtube_id'].value + '.mp4'"
|
||||
)
|
||||
data = {
|
||||
"query": {"bool": {"must_not": [{"script": {"script": script}}]}},
|
||||
"_source": [
|
||||
"youtube_id",
|
||||
"media_url",
|
||||
"channel.channel_id",
|
||||
"subtitles",
|
||||
],
|
||||
}
|
||||
response = IndexPaginate("ta_video", data).get_results()
|
||||
|
||||
return response
|
||||
|
||||
def create_folders(self, to_migrate):
|
||||
"""create required channel folders"""
|
||||
host_uid = EnvironmentSettings.HOST_UID
|
||||
host_gid = EnvironmentSettings.HOST_GID
|
||||
all_channel_ids = {i["channel"]["channel_id"] for i in to_migrate}
|
||||
|
||||
for channel_id in all_channel_ids:
|
||||
new_folder = os.path.join(self.videos, channel_id)
|
||||
os.makedirs(new_folder, exist_ok=True)
|
||||
if host_uid and host_gid:
|
||||
os.chown(new_folder, host_uid, host_gid)
|
||||
|
||||
return len(all_channel_ids)
|
||||
|
||||
def migrate_videos(self, to_migrate):
|
||||
"""migrate all videos of channel"""
|
||||
total = len(to_migrate)
|
||||
for idx, video in enumerate(to_migrate):
|
||||
new_media_url = self._move_video_file(video)
|
||||
if not new_media_url:
|
||||
continue
|
||||
|
||||
all_subtitles = self._move_subtitles(video)
|
||||
action = {
|
||||
"update": {"_id": video["youtube_id"], "_index": "ta_video"}
|
||||
}
|
||||
source = {"doc": {"media_url": new_media_url}}
|
||||
if all_subtitles:
|
||||
source["doc"].update({"subtitles": all_subtitles})
|
||||
|
||||
self.bulk_list.append(json.dumps(action))
|
||||
self.bulk_list.append(json.dumps(source))
|
||||
if idx % 1000 == 0:
|
||||
print(f"processing migration [{idx}/{total}]")
|
||||
self.send_bulk()
|
||||
|
||||
def _move_video_file(self, video):
|
||||
"""move video file to new location"""
|
||||
old_path = os.path.join(self.videos, video["media_url"])
|
||||
if not os.path.exists(old_path):
|
||||
print(f"did not find expected video at {old_path}")
|
||||
return False
|
||||
|
||||
new_media_url = os.path.join(
|
||||
video["channel"]["channel_id"], video["youtube_id"] + ".mp4"
|
||||
)
|
||||
new_path = os.path.join(self.videos, new_media_url)
|
||||
os.rename(old_path, new_path)
|
||||
|
||||
return new_media_url
|
||||
|
||||
def _move_subtitles(self, video):
|
||||
"""move subtitle files to new location"""
|
||||
all_subtitles = video.get("subtitles")
|
||||
if not all_subtitles:
|
||||
return False
|
||||
|
||||
for subtitle in all_subtitles:
|
||||
old_path = os.path.join(self.videos, subtitle["media_url"])
|
||||
if not os.path.exists(old_path):
|
||||
print(f"did not find expected subtitle at {old_path}")
|
||||
continue
|
||||
|
||||
new_media_url = os.path.join(
|
||||
video["channel"]["channel_id"],
|
||||
f"{video.get('youtube_id')}.{subtitle.get('lang')}.vtt",
|
||||
)
|
||||
new_path = os.path.join(self.videos, new_media_url)
|
||||
os.rename(old_path, new_path)
|
||||
subtitle["media_url"] = new_media_url
|
||||
|
||||
return all_subtitles
|
||||
|
||||
def send_bulk(self):
|
||||
"""send bulk request to update index with new urls"""
|
||||
if not self.bulk_list:
|
||||
print("nothing to update")
|
||||
return
|
||||
|
||||
self.bulk_list.append("\n")
|
||||
path = "_bulk?refresh=true"
|
||||
data = "\n".join(self.bulk_list)
|
||||
response, status = ElasticWrap(path).post(data=data, ndjson=True)
|
||||
if not status == 200:
|
||||
print(response)
|
||||
|
||||
self.bulk_list = []
|
||||
|
||||
def delete_old(self):
|
||||
"""delete old empty folders"""
|
||||
all_folders = ignore_filelist(os.listdir(self.videos))
|
||||
for folder in all_folders:
|
||||
folder_path = os.path.join(self.videos, folder)
|
||||
if not os.path.isdir(folder_path):
|
||||
continue
|
||||
|
||||
if not ignore_filelist(os.listdir(folder_path)):
|
||||
shutil.rmtree(folder_path)
|
||||
239
tubearchivist/config/management/commands/ta_startup.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Functionality:
|
||||
- Application startup
|
||||
- Apply migrations
|
||||
"""
|
||||
|
||||
import os
|
||||
from time import sleep
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from home.src.es.index_setup import ElasitIndexWrap
|
||||
from home.src.es.snapshot import ElasticSnapshot
|
||||
from home.src.ta.config import AppConfig, ReleaseVersion
|
||||
from home.src.ta.helper import clear_dl_cache
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
from home.src.ta.ta_redis import RedisArchivist
|
||||
from home.src.ta.task_manager import TaskManager
|
||||
from home.src.ta.users import UserConfig
|
||||
|
||||
TOPIC = """
|
||||
|
||||
#######################
|
||||
# Application Start #
|
||||
#######################
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""command framework"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""run all commands"""
|
||||
self.stdout.write(TOPIC)
|
||||
self._sync_redis_state()
|
||||
self._make_folders()
|
||||
self._clear_redis_keys()
|
||||
self._clear_tasks()
|
||||
self._clear_dl_cache()
|
||||
self._version_check()
|
||||
self._mig_index_setup()
|
||||
self._mig_snapshot_check()
|
||||
self._mig_move_users_to_es()
|
||||
|
||||
def _sync_redis_state(self):
|
||||
"""make sure redis gets new config.json values"""
|
||||
self.stdout.write("[1] set new config.json values")
|
||||
needs_update = AppConfig().load_new_defaults()
|
||||
if needs_update:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✓ new config values set")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(" no new config values"))
|
||||
|
||||
def _make_folders(self):
|
||||
"""make expected cache folders"""
|
||||
self.stdout.write("[2] create expected cache folders")
|
||||
folders = [
|
||||
"backup",
|
||||
"channels",
|
||||
"download",
|
||||
"import",
|
||||
"playlists",
|
||||
"videos",
|
||||
]
|
||||
cache_dir = EnvironmentSettings.CACHE_DIR
|
||||
for folder in folders:
|
||||
folder_path = os.path.join(cache_dir, folder)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(" ✓ expected folders created"))
|
||||
|
||||
def _clear_redis_keys(self):
|
||||
"""make sure there are no leftover locks or keys set in redis"""
|
||||
self.stdout.write("[3] clear leftover keys in redis")
|
||||
all_keys = [
|
||||
"dl_queue_id",
|
||||
"dl_queue",
|
||||
"downloading",
|
||||
"manual_import",
|
||||
"reindex",
|
||||
"rescan",
|
||||
"run_backup",
|
||||
"startup_check",
|
||||
"reindex:ta_video",
|
||||
"reindex:ta_channel",
|
||||
"reindex:ta_playlist",
|
||||
]
|
||||
|
||||
redis_con = RedisArchivist()
|
||||
has_changed = False
|
||||
for key in all_keys:
|
||||
if redis_con.del_message(key):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✓ cleared key {key}")
|
||||
)
|
||||
has_changed = True
|
||||
|
||||
if not has_changed:
|
||||
self.stdout.write(self.style.SUCCESS(" no keys found"))
|
||||
|
||||
def _clear_tasks(self):
|
||||
"""clear tasks and messages"""
|
||||
self.stdout.write("[4] clear task leftovers")
|
||||
TaskManager().fail_pending()
|
||||
redis_con = RedisArchivist()
|
||||
to_delete = redis_con.list_keys("message:")
|
||||
if to_delete:
|
||||
for key in to_delete:
|
||||
redis_con.del_message(key)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages")
|
||||
)
|
||||
|
||||
def _clear_dl_cache(self):
|
||||
"""clear leftover files from dl cache"""
|
||||
self.stdout.write("[5] clear leftover files from dl cache")
|
||||
leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR)
|
||||
if leftover_files:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✓ cleared {leftover_files} files")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(" no files found"))
|
||||
|
||||
def _version_check(self):
|
||||
"""remove new release key if updated now"""
|
||||
self.stdout.write("[6] check for first run after update")
|
||||
new_version = ReleaseVersion().is_updated()
|
||||
if new_version:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✓ update to {new_version} completed")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(" no new update found"))
|
||||
|
||||
def _mig_index_setup(self):
|
||||
"""migration: validate index mappings"""
|
||||
self.stdout.write("[MIGRATION] validate index mappings")
|
||||
ElasitIndexWrap().setup()
|
||||
|
||||
def _mig_snapshot_check(self):
|
||||
"""migration setup snapshots"""
|
||||
self.stdout.write("[MIGRATION] setup snapshots")
|
||||
ElasticSnapshot().setup()
|
||||
|
||||
def _mig_move_users_to_es(self): # noqa: C901
|
||||
"""migration: update from 0.4.1 to 0.4.2 move user config to ES"""
|
||||
self.stdout.write("[MIGRATION] move user configuration to ES")
|
||||
redis = RedisArchivist()
|
||||
|
||||
# 1: Find all users in Redis
|
||||
users = {i.split(":")[0] for i in redis.list_keys("[0-9]*:")}
|
||||
if not users:
|
||||
self.stdout.write(" no users needed migrating to ES")
|
||||
return
|
||||
|
||||
# 2: Write all Redis user settings to ES
|
||||
# 3: Remove user settings from Redis
|
||||
try:
|
||||
for user in users:
|
||||
new_conf = UserConfig(user)
|
||||
|
||||
stylesheet_key = f"{user}:color"
|
||||
stylesheet = redis.get_message(stylesheet_key).get("status")
|
||||
if stylesheet:
|
||||
new_conf.set_value("stylesheet", stylesheet)
|
||||
redis.del_message(stylesheet_key)
|
||||
|
||||
sort_by_key = f"{user}:sort_by"
|
||||
sort_by = redis.get_message(sort_by_key).get("status")
|
||||
if sort_by:
|
||||
new_conf.set_value("sort_by", sort_by)
|
||||
redis.del_message(sort_by_key)
|
||||
|
||||
page_size_key = f"{user}:page_size"
|
||||
page_size = redis.get_message(page_size_key).get("status")
|
||||
if page_size:
|
||||
new_conf.set_value("page_size", page_size)
|
||||
redis.del_message(page_size_key)
|
||||
|
||||
sort_order_key = f"{user}:sort_order"
|
||||
sort_order = redis.get_message(sort_order_key).get("status")
|
||||
if sort_order:
|
||||
new_conf.set_value("sort_order", sort_order)
|
||||
redis.del_message(sort_order_key)
|
||||
|
||||
grid_items_key = f"{user}:grid_items"
|
||||
grid_items = redis.get_message(grid_items_key).get("status")
|
||||
if grid_items:
|
||||
new_conf.set_value("grid_items", grid_items)
|
||||
redis.del_message(grid_items_key)
|
||||
|
||||
hide_watch_key = f"{user}:hide_watched"
|
||||
hide_watch = redis.get_message(hide_watch_key).get("status")
|
||||
if hide_watch:
|
||||
new_conf.set_value("hide_watched", hide_watch)
|
||||
redis.del_message(hide_watch_key)
|
||||
|
||||
ignore_only_key = f"{user}:show_ignored_only"
|
||||
ignore_only = redis.get_message(ignore_only_key).get("status")
|
||||
if ignore_only:
|
||||
new_conf.set_value("show_ignored_only", ignore_only)
|
||||
redis.del_message(ignore_only_key)
|
||||
|
||||
subed_only_key = f"{user}:show_subed_only"
|
||||
subed_only = redis.get_message(subed_only_key).get("status")
|
||||
if subed_only:
|
||||
new_conf.set_value("show_subed_only", subed_only)
|
||||
redis.del_message(subed_only_key)
|
||||
|
||||
for view in ["channel", "playlist", "home", "downloads"]:
|
||||
view_key = f"{user}:view:{view}"
|
||||
view_style = redis.get_message(view_key).get("status")
|
||||
if view_style:
|
||||
new_conf.set_value(f"view_style_{view}", view_style)
|
||||
redis.del_message(view_key)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f" ✓ Settings for user '{user}' migrated to ES"
|
||||
)
|
||||
)
|
||||
except Exception as err:
|
||||
message = " 🗙 user migration to ES failed"
|
||||
self.stdout.write(self.style.ERROR(message))
|
||||
self.stdout.write(self.style.ERROR(err))
|
||||
sleep(60)
|
||||
raise CommandError(message) from err
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
" ✓ Settings for all users migrated to ES"
|
||||
)
|
||||
)
|
||||
@@ -10,9 +10,16 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.2/ref/settings/
|
||||
"""
|
||||
|
||||
from os import environ
|
||||
import hashlib
|
||||
from os import environ, path
|
||||
from pathlib import Path
|
||||
|
||||
import ldap
|
||||
from corsheaders.defaults import default_headers
|
||||
from django_auth_ldap.config import LDAPSearch
|
||||
from home.src.ta.helper import ta_host_parser
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -20,14 +27,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "Fvid^aUL6LohRZz*kZFvq85B&JW&kB9o*#jdzWsdWE8*XkCLR8"
|
||||
PW_HASH = hashlib.sha256(EnvironmentSettings.TA_PASSWORD.encode())
|
||||
SECRET_KEY = PW_HASH.hexdigest()
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = bool(environ.get("DJANGO_DEBUG"))
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(environ["TA_HOST"])
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -38,20 +44,27 @@ INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"corsheaders",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.humanize",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"api",
|
||||
"config",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"home.src.ta.health.HealthCheckMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
@@ -74,14 +87,105 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
if bool(environ.get("TA_LDAP")):
|
||||
# pylint: disable=global-at-module-level
|
||||
global AUTH_LDAP_SERVER_URI
|
||||
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
|
||||
|
||||
global AUTH_LDAP_BIND_DN
|
||||
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
|
||||
|
||||
global AUTH_LDAP_BIND_PASSWORD
|
||||
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
|
||||
|
||||
"""
|
||||
Since these are new environment variables, taking the opporunity to use
|
||||
more accurate env names.
|
||||
Given Names are *_technically_* different from Personal names, as people
|
||||
who change their names have different given names and personal names,
|
||||
and they go by personal names. Additionally, "LastName" is actually
|
||||
incorrect for many cultures, such as Korea, where the
|
||||
family name comes first, and the personal name comes last.
|
||||
|
||||
But we all know people are going to try to guess at these, so still want
|
||||
to include names that people will guess, hence using first/last as well.
|
||||
"""
|
||||
|
||||
# Attribute mapping options
|
||||
|
||||
global AUTH_LDAP_USER_ATTR_MAP_USERNAME
|
||||
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
|
||||
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
|
||||
or "uid"
|
||||
)
|
||||
|
||||
global AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME
|
||||
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
|
||||
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
|
||||
or "givenName"
|
||||
)
|
||||
|
||||
global AUTH_LDAP_USER_ATTR_MAP_SURNAME
|
||||
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
|
||||
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
|
||||
or "sn"
|
||||
)
|
||||
|
||||
global AUTH_LDAP_USER_ATTR_MAP_EMAIL
|
||||
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
|
||||
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
|
||||
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
|
||||
or "mail"
|
||||
)
|
||||
|
||||
global AUTH_LDAP_USER_BASE
|
||||
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
|
||||
|
||||
global AUTH_LDAP_USER_FILTER
|
||||
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
|
||||
|
||||
global AUTH_LDAP_USER_SEARCH
|
||||
# pylint: disable=no-member
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
AUTH_LDAP_USER_BASE,
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(&("
|
||||
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
|
||||
+ "=%(user)s)"
|
||||
+ AUTH_LDAP_USER_FILTER
|
||||
+ ")",
|
||||
)
|
||||
|
||||
global AUTH_LDAP_USER_ATTR_MAP
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
|
||||
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
|
||||
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
|
||||
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
|
||||
}
|
||||
|
||||
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
|
||||
global AUTH_LDAP_GLOBAL_OPTIONS
|
||||
AUTH_LDAP_GLOBAL_OPTIONS = {
|
||||
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
|
||||
}
|
||||
|
||||
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||
|
||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||
DB_PATH = path.join(CACHE_DIR, "db.sqlite3")
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"NAME": DB_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,18 +208,29 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "home.Account"
|
||||
|
||||
# Forward-auth authentication
|
||||
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
||||
TA_AUTH_PROXY_USERNAME_HEADER = (
|
||||
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER"
|
||||
)
|
||||
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
|
||||
|
||||
MIDDLEWARE.append("home.src.ta.auth.HttpRemoteUserMiddleware")
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
"django.contrib.auth.backends.RemoteUserBackend",
|
||||
)
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
TIME_ZONE = EnvironmentSettings.TZ
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
@@ -123,22 +238,35 @@ USE_TZ = True
|
||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
# STATICFILES_DIRS = [
|
||||
# str(BASE_DIR.joinpath('static')),
|
||||
# '/cache/'
|
||||
# ]
|
||||
|
||||
# STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = (str(BASE_DIR.joinpath("static")),)
|
||||
# MEDIA_ROOT = str(BASE_DIR.joinpath('media'))
|
||||
# MEDIA_URL = '/media/'
|
||||
|
||||
STATIC_ROOT = str(BASE_DIR.joinpath("staticfiles"))
|
||||
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_URL = "/login/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# Cors needed for browser extension
|
||||
# background.js makes the request so HTTP_ORIGIN will be from extension
|
||||
if environ.get("DISABLE_CORS"):
|
||||
# disable cors
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
else:
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r"moz-extension://*",
|
||||
r"chrome-extension://*",
|
||||
]
|
||||
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
|
||||
|
||||
|
||||
CORS_ALLOW_HEADERS = list(default_headers) + [
|
||||
"mode",
|
||||
]
|
||||
|
||||
# TA application settings
|
||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||
TA_VERSION = "v0.4.5"
|
||||
|
||||
@@ -18,5 +18,6 @@ from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("home.urls")),
|
||||
path("api/", include("api.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
from django.contrib import admin # noqa: F401 - Unused import
|
||||
"""custom admin classes"""
|
||||
|
||||
# Register your models here.
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from .models import Account
|
||||
|
||||
|
||||
class HomeAdmin(BaseUserAdmin):
|
||||
"""register in admin page"""
|
||||
|
||||
list_display = ("name", "is_staff", "is_superuser")
|
||||
list_filter = ("is_superuser",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("is_staff", "is_superuser", "password")}),
|
||||
("Personal info", {"fields": ("name",)}),
|
||||
("Groups", {"fields": ("groups",)}),
|
||||
("Permissions", {"fields": ("user_permissions",)}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("is_staff", "is_superuser", "password1", "password2")},
|
||||
),
|
||||
("Personal info", {"fields": ("name",)}),
|
||||
("Groups", {"fields": ("groups",)}),
|
||||
("Permissions", {"fields": ("user_permissions",)}),
|
||||
)
|
||||
|
||||
search_fields = ("name",)
|
||||
ordering = ("name",)
|
||||
filter_horizontal = ()
|
||||
|
||||
|
||||
admin.site.register(Account, HomeAdmin)
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
"""handle custom startup functions"""
|
||||
|
||||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
from home.src.config import AppConfig as ArchivistConfig
|
||||
from home.src.helper import RedisArchivist
|
||||
from home.src.index_management import index_check
|
||||
from home.src.thumbnails import validate_thumbnails
|
||||
|
||||
|
||||
def sync_redis_state():
|
||||
"""make sure redis gets new config.json values"""
|
||||
print("sync redis")
|
||||
config_handler = ArchivistConfig()
|
||||
config_handler.load_new_defaults()
|
||||
|
||||
|
||||
def make_folders():
|
||||
"""make needed cache folders here so docker doesn't mess it up"""
|
||||
folders = ["download", "channels", "videos", "import", "backup"]
|
||||
config = ArchivistConfig().config
|
||||
cache_dir = config["application"]["cache_dir"]
|
||||
for folder in folders:
|
||||
folder_path = os.path.join(cache_dir, folder)
|
||||
try:
|
||||
os.makedirs(folder_path)
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
|
||||
def release_lock():
|
||||
"""make sure there are no leftover locks set in redis on container start"""
|
||||
all_locks = ["manual_import", "downloading", "dl_queue", "dl_queue_id"]
|
||||
for lock in all_locks:
|
||||
response = RedisArchivist().del_message(lock)
|
||||
if response:
|
||||
print("deleted leftover key from redis: " + lock)
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
@@ -43,10 +8,3 @@ class HomeConfig(AppConfig):
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "home"
|
||||
|
||||
def ready(self):
|
||||
release_lock()
|
||||
index_check()
|
||||
sync_redis_state()
|
||||
make_folders()
|
||||
validate_thumbnails()
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
{
|
||||
"archive": {
|
||||
"sort": "published",
|
||||
"page_size": 12
|
||||
},
|
||||
"default_view": {
|
||||
"home": "grid",
|
||||
"channel": "list",
|
||||
"downloads": "list"
|
||||
},
|
||||
"subscriptions": {
|
||||
"auto_search": false,
|
||||
"auto_download": false,
|
||||
"channel_size": 50
|
||||
"channel_size": 50,
|
||||
"live_channel_size": 50,
|
||||
"shorts_channel_size": 50,
|
||||
"auto_start": false
|
||||
},
|
||||
"downloads": {
|
||||
"limit_count": false,
|
||||
"limit_speed": false,
|
||||
"sleep_interval": 3,
|
||||
"autodelete_days": false,
|
||||
"format": false,
|
||||
"format_sort": false,
|
||||
"add_metadata": false,
|
||||
"add_thumbnail": false
|
||||
"add_thumbnail": false,
|
||||
"subtitle": false,
|
||||
"subtitle_source": false,
|
||||
"subtitle_index": false,
|
||||
"comment_max": false,
|
||||
"comment_sort": "top",
|
||||
"cookie_import": false,
|
||||
"throttledratelimit": false,
|
||||
"extractor_lang": false,
|
||||
"integrate_ryd": false,
|
||||
"integrate_sponsorblock": false
|
||||
},
|
||||
"application": {
|
||||
"app_root": "/app",
|
||||
"cache_dir": "/cache",
|
||||
"videos": "/youtube",
|
||||
"file_template": "%(id)s_%(title)s.mp4",
|
||||
"colors": "dark"
|
||||
"enable_snapshot": true
|
||||
},
|
||||
"scheduler": {
|
||||
"update_subscribed": false,
|
||||
"update_subscribed_notify": false,
|
||||
"download_pending": false,
|
||||
"download_pending_notify": false,
|
||||
"check_reindex": {"minute": "0", "hour": "12", "day_of_week": "*"},
|
||||
"check_reindex_notify": false,
|
||||
"check_reindex_days": 90,
|
||||
"thumbnail_check": {"minute": "0", "hour": "17", "day_of_week": "*"},
|
||||
"run_backup": false,
|
||||
"run_backup_rotate": 5,
|
||||
"version_check": "rand-d"
|
||||
}
|
||||
}
|
||||
|
||||
35
tubearchivist/home/migrations/0001_initial.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-02 06:49
|
||||
|
||||
from django.db import migrations, models
|
||||
import home.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Account',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('name', models.CharField(max_length=150, unique=True)),
|
||||
('is_staff', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', home.models.AccountManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,53 @@
|
||||
from django.db import models # noqa: F401 - Unused import
|
||||
"""custom models"""
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
BaseUserManager,
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class AccountManager(BaseUserManager):
|
||||
"""manage user creation methods"""
|
||||
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, name, password, **extra_fields):
|
||||
"""create regular user private"""
|
||||
values = [name, password]
|
||||
field_value_map = dict(zip(self.model.REQUIRED_FIELDS, values))
|
||||
for field_name, value in field_value_map.items():
|
||||
if not value:
|
||||
raise ValueError(f"The {field_name} value must be set")
|
||||
|
||||
user = self.model(name=name, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, name, password):
|
||||
"""create regular user public"""
|
||||
return self._create_user(name, password)
|
||||
|
||||
def create_superuser(self, name, password, **extra_fields):
|
||||
"""create super user"""
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self._create_user(name, password, **extra_fields)
|
||||
|
||||
|
||||
class Account(AbstractBaseUser, PermissionsMixin):
|
||||
"""handle account creation"""
|
||||
|
||||
name = models.CharField(max_length=150, unique=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
objects = AccountManager()
|
||||
|
||||
USERNAME_FIELD = "name"
|
||||
REQUIRED_FIELDS = ["password"]
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""
|
||||
Functionality:
|
||||
- read and write config
|
||||
- load config variables into redis
|
||||
- needs to be a separate module to avoid circular import
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from home.src.helper import RedisArchivist
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""handle user settings and application variables"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = self.get_config()
|
||||
|
||||
def get_config(self):
|
||||
"""get config from default file or redis if changed"""
|
||||
config = self.get_config_redis()
|
||||
if not config:
|
||||
config = self.get_config_file()
|
||||
|
||||
config["application"].update(self.get_config_env())
|
||||
return config
|
||||
|
||||
def get_config_file(self):
|
||||
"""read the defaults from config.json"""
|
||||
with open("home/config.json", "r", encoding="utf-8") as f:
|
||||
config_str = f.read()
|
||||
config_file = json.loads(config_str)
|
||||
|
||||
config_file["application"].update(self.get_config_env())
|
||||
|
||||
return config_file
|
||||
|
||||
@staticmethod
|
||||
def get_config_env():
|
||||
"""read environment application variables"""
|
||||
application = {
|
||||
"REDIS_HOST": os.environ.get("REDIS_HOST"),
|
||||
"es_url": os.environ.get("ES_URL"),
|
||||
"HOST_UID": int(os.environ.get("HOST_UID")),
|
||||
"HOST_GID": int(os.environ.get("HOST_GID")),
|
||||
}
|
||||
|
||||
return application
|
||||
|
||||
@staticmethod
|
||||
def get_config_redis():
|
||||
"""read config json set from redis to overwrite defaults"""
|
||||
config = RedisArchivist().get_message("config")
|
||||
if not list(config.values())[0]:
|
||||
return False
|
||||
|
||||
return config
|
||||
|
||||
def update_config(self, form_post):
|
||||
"""update config values from settings form"""
|
||||
config = self.config
|
||||
for key, value in form_post.items():
|
||||
to_write = value[0]
|
||||
if len(to_write):
|
||||
if to_write == "0":
|
||||
to_write = False
|
||||
elif to_write == "1":
|
||||
to_write = True
|
||||
elif to_write.isdigit():
|
||||
to_write = int(to_write)
|
||||
|
||||
config_dict, config_value = key.split(".")
|
||||
config[config_dict][config_value] = to_write
|
||||
|
||||
RedisArchivist().set_message("config", config, expire=False)
|
||||
|
||||
def load_new_defaults(self):
|
||||
"""check config.json for missing defaults"""
|
||||
default_config = self.get_config_file()
|
||||
redis_config = self.get_config_redis()
|
||||
|
||||
# check for customizations
|
||||
if not redis_config:
|
||||
return
|
||||
|
||||
needs_update = False
|
||||
|
||||
for key, value in default_config.items():
|
||||
# missing whole main key
|
||||
if key not in redis_config:
|
||||
redis_config.update({key: value})
|
||||
needs_update = True
|
||||
continue
|
||||
|
||||
# missing nested values
|
||||
for sub_key, sub_value in value.items():
|
||||
if sub_key not in redis_config[key].keys():
|
||||
redis_config[key].update({sub_key: sub_value})
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
RedisArchivist().set_message("config", redis_config, expire=False)
|
||||
@@ -1,601 +0,0 @@
|
||||
"""
|
||||
Functionality:
|
||||
- handele the download queue
|
||||
- manage subscriptions to channels
|
||||
- downloading videos
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
import yt_dlp as youtube_dl
|
||||
from home.src.config import AppConfig
|
||||
from home.src.helper import (
|
||||
DurationConverter,
|
||||
RedisArchivist,
|
||||
RedisQueue,
|
||||
clean_string,
|
||||
ignore_filelist,
|
||||
)
|
||||
from home.src.index import YoutubeChannel, index_new_video
|
||||
|
||||
|
||||
class PendingList:
|
||||
"""manage the pending videos list"""
|
||||
|
||||
CONFIG = AppConfig().config
|
||||
ES_URL = CONFIG["application"]["es_url"]
|
||||
VIDEOS = CONFIG["application"]["videos"]
|
||||
|
||||
@staticmethod
|
||||
def parse_url_list(youtube_ids):
|
||||
"""extract youtube ids from list"""
|
||||
missing_videos = []
|
||||
for entry in youtube_ids:
|
||||
# notify
|
||||
mess_dict = {
|
||||
"status": "pending",
|
||||
"level": "info",
|
||||
"title": "Adding to download queue.",
|
||||
"message": "Extracting lists",
|
||||
}
|
||||
RedisArchivist().set_message("progress:download", mess_dict)
|
||||
# extract
|
||||
url = entry["url"]
|
||||
url_type = entry["type"]
|
||||
if url_type == "video":
|
||||
missing_videos.append(url)
|
||||
elif url_type == "channel":
|
||||
youtube_ids = ChannelSubscription().get_last_youtube_videos(
|
||||
url, limit=False
|
||||
)
|
||||
missing_videos = missing_videos + youtube_ids
|
||||
elif url_type == "playlist":
|
||||
youtube_ids = playlist_extractor(url)
|
||||
missing_videos = missing_videos + youtube_ids
|
||||
|
||||
return missing_videos
|
||||
|
||||
def add_to_pending(self, missing_videos):
|
||||
"""build the bulk json data from pending"""
|
||||
# check if channel is indexed
|
||||
channel_handler = ChannelSubscription()
|
||||
all_indexed = channel_handler.get_channels(subscribed_only=False)
|
||||
all_channel_ids = [i["channel_id"] for i in all_indexed]
|
||||
# check if already there
|
||||
all_downloaded = self.get_all_downloaded()
|
||||
# loop
|
||||
bulk_list = []
|
||||
all_videos_added = []
|
||||
for video in missing_videos:
|
||||
if isinstance(video, str):
|
||||
youtube_id = video
|
||||
elif isinstance(video, tuple):
|
||||
youtube_id = video[0]
|
||||
if youtube_id in all_downloaded:
|
||||
# skip already downloaded
|
||||
continue
|
||||
video = self.get_youtube_details(youtube_id)
|
||||
# skip on download error
|
||||
if not video:
|
||||
continue
|
||||
|
||||
if video["channel_id"] in all_channel_ids:
|
||||
video["channel_indexed"] = True
|
||||
else:
|
||||
video["channel_indexed"] = False
|
||||
thumb_url = video["vid_thumb_url"]
|
||||
video["status"] = "pending"
|
||||
action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
|
||||
bulk_list.append(json.dumps(action))
|
||||
bulk_list.append(json.dumps(video))
|
||||
all_videos_added.append((youtube_id, thumb_url))
|
||||
# notify
|
||||
mess_dict = {
|
||||
"status": "pending",
|
||||
"level": "info",
|
||||
"title": "Adding to download queue.",
|
||||
"message": "Processing IDs...",
|
||||
}
|
||||
RedisArchivist().set_message("progress:download", mess_dict)
|
||||
# add last newline
|
||||
bulk_list.append("\n")
|
||||
query_str = "\n".join(bulk_list)
|
||||
headers = {"Content-type": "application/x-ndjson"}
|
||||
url = self.ES_URL + "/_bulk"
|
||||
request = requests.post(url, data=query_str, headers=headers)
|
||||
if not request.ok:
|
||||
print(request)
|
||||
|
||||
return all_videos_added
|
||||
|
||||
@staticmethod
|
||||
def get_youtube_details(youtube_id):
|
||||
"""get details from youtubedl for single pending video"""
|
||||
obs = {
|
||||
"default_search": "ytsearch",
|
||||
"quiet": True,
|
||||
"skip_download": True,
|
||||
}
|
||||
try:
|
||||
vid = youtube_dl.YoutubeDL(obs).extract_info(youtube_id)
|
||||
except youtube_dl.utils.DownloadError:
|
||||
print("failed to extract info for: " + youtube_id)
|
||||
return False
|
||||
# stop if video is streaming live now
|
||||
if vid["is_live"]:
|
||||
return False
|
||||
# parse response
|
||||
seconds = vid["duration"]
|
||||
duration_str = DurationConverter.get_str(seconds)
|
||||
if duration_str == "NA":
|
||||
print(f"skip extracting duration for: {youtube_id}")
|
||||
upload_date = vid["upload_date"]
|
||||
upload_dt = datetime.strptime(upload_date, "%Y%m%d")
|
||||
published = upload_dt.strftime("%Y-%m-%d")
|
||||
# build dict
|
||||
youtube_details = {
|
||||
"youtube_id": youtube_id,
|
||||
"channel_name": vid["channel"],
|
||||
"vid_thumb_url": vid["thumbnail"],
|
||||
"title": vid["title"],
|
||||
"channel_id": vid["channel_id"],
|
||||
"duration": duration_str,
|
||||
"published": published,
|
||||
"timestamp": int(datetime.now().strftime("%s")),
|
||||
}
|
||||
return youtube_details
|
||||
|
||||
def get_all_pending(self):
|
||||
"""get a list of all pending videos in ta_download"""
|
||||
headers = {"Content-type": "application/json"}
|
||||
# get PIT ID
|
||||
url = self.ES_URL + "/ta_download/_pit?keep_alive=1m"
|
||||
response = requests.post(url)
|
||||
json_data = json.loads(response.text)
|
||||
pit_id = json_data["id"]
|
||||
# query
|
||||
data = {
|
||||
"size": 50,
|
||||
"query": {"match_all": {}},
|
||||
"pit": {"id": pit_id, "keep_alive": "1m"},
|
||||
"sort": [{"timestamp": {"order": "asc"}}],
|
||||
}
|
||||
query_str = json.dumps(data)
|
||||
url = self.ES_URL + "/_search"
|
||||
all_pending = []
|
||||
all_ignore = []
|
||||
while True:
|
||||
response = requests.get(url, data=query_str, headers=headers)
|
||||
json_data = json.loads(response.text)
|
||||
all_hits = json_data["hits"]["hits"]
|
||||
if all_hits:
|
||||
for hit in all_hits:
|
||||
status = hit["_source"]["status"]
|
||||
if status == "pending":
|
||||
all_pending.append(hit["_source"])
|
||||
elif status == "ignore":
|
||||
all_ignore.append(hit["_source"])
|
||||
search_after = hit["sort"]
|
||||
# update search_after with last hit data
|
||||
data["search_after"] = search_after
|
||||
query_str = json.dumps(data)
|
||||
else:
|
||||
break
|
||||
# clean up PIT
|
||||
query_str = json.dumps({"id": pit_id})
|
||||
requests.delete(self.ES_URL + "/_pit", data=query_str, headers=headers)
|
||||
return all_pending, all_ignore
|
||||
|
||||
def get_all_indexed(self):
|
||||
"""get a list of all videos indexed"""
|
||||
headers = {"Content-type": "application/json"}
|
||||
# get PIT ID
|
||||
url = self.ES_URL + "/ta_video/_pit?keep_alive=1m"
|
||||
response = requests.post(url)
|
||||
json_data = json.loads(response.text)
|
||||
pit_id = json_data["id"]
|
||||
# query
|
||||
data = {
|
||||
"size": 500,
|
||||
"query": {"match_all": {}},
|
||||
"pit": {"id": pit_id, "keep_alive": "1m"},
|
||||
"sort": [{"published": {"order": "desc"}}],
|
||||
}
|
||||
query_str = json.dumps(data)
|
||||
url = self.ES_URL + "/_search"
|
||||
all_indexed = []
|
||||
while True:
|
||||
response = requests.get(url, data=query_str, headers=headers)
|
||||
json_data = json.loads(response.text)
|
||||
all_hits = json_data["hits"]["hits"]
|
||||
if all_hits:
|
||||
for hit in all_hits:
|
||||
all_indexed.append(hit)
|
||||
search_after = hit["sort"]
|
||||
# update search_after with last hit data
|
||||
data["search_after"] = search_after
|
||||
query_str = json.dumps(data)
|
||||
else:
|
||||
break
|
||||
# clean up PIT
|
||||
query_str = json.dumps({"id": pit_id})
|
||||
requests.delete(self.ES_URL + "/_pit", data=query_str, headers=headers)
|
||||
return all_indexed
|
||||
|
||||
def get_all_downloaded(self):
|
||||
"""get a list of all videos in archive"""
|
||||
channel_folders = os.listdir(self.VIDEOS)
|
||||
all_channel_folders = ignore_filelist(channel_folders)
|
||||
all_downloaded = []
|
||||
for channel_folder in all_channel_folders:
|
||||
channel_path = os.path.join(self.VIDEOS, channel_folder)
|
||||
videos = os.listdir(channel_path)
|
||||
all_videos = ignore_filelist(videos)
|
||||
youtube_vids = [i[9:20] for i in all_videos]
|
||||
for youtube_id in youtube_vids:
|
||||
all_downloaded.append(youtube_id)
|
||||
return all_downloaded
|
||||
|
||||
def delete_from_pending(self, youtube_id):
|
||||
"""delete the youtube_id from ta_download"""
|
||||
url = f"{self.ES_URL}/ta_download/_doc/{youtube_id}"
|
||||
response = requests.delete(url)
|
||||
if not response.ok:
|
||||
print(response.text)
|
||||
|
||||
def ignore_from_pending(self, ignore_list):
|
||||
"""build the bulk query string"""
|
||||
|
||||
stamp = int(datetime.now().strftime("%s"))
|
||||
bulk_list = []
|
||||
|
||||
for youtube_id in ignore_list:
|
||||
action = {"update": {"_id": youtube_id, "_index": "ta_download"}}
|
||||
source = {"doc": {"status": "ignore", "timestamp": stamp}}
|
||||
bulk_list.append(json.dumps(action))
|
||||
bulk_list.append(json.dumps(source))
|
||||
|
||||
# add last newline
|
||||
bulk_list.append("\n")
|
||||
query_str = "\n".join(bulk_list)
|
||||
|
||||
headers = {"Content-type": "application/x-ndjson"}
|
||||
url = self.ES_URL + "/_bulk"
|
||||
request = requests.post(url, data=query_str, headers=headers)
|
||||
mess_dict = {
|
||||
"status": "ignore",
|
||||
"level": "info",
|
||||
"title": "Added to ignore list",
|
||||
"message": "",
|
||||
}
|
||||
RedisArchivist().set_message("progress:download", mess_dict)
|
||||
if not request.ok:
|
||||
print(request)
|
||||
|
||||
|
||||
class ChannelSubscription:
|
||||
"""manage the list of channels subscribed"""
|
||||
|
||||
def __init__(self):
|
||||
config = AppConfig().config
|
||||
self.es_url = config["application"]["es_url"]
|
||||
self.channel_size = config["subscriptions"]["channel_size"]
|
||||
|
||||
def get_channels(self, subscribed_only=True):
|
||||
"""get a list of all channels subscribed to"""
|
||||
headers = {"Content-type": "application/json"}
|
||||
# get PIT ID
|
||||
url = self.es_url + "/ta_channel/_pit?keep_alive=1m"
|
||||
response = requests.post(url)
|
||||
json_data = json.loads(response.text)
|
||||
pit_id = json_data["id"]
|
||||
# query
|
||||
if subscribed_only:
|
||||
data = {
|
||||
"query": {"term": {"channel_subscribed": {"value": True}}},
|
||||
"size": 50,
|
||||
"pit": {"id": pit_id, "keep_alive": "1m"},
|
||||
"sort": [{"channel_name.keyword": {"order": "asc"}}],
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"query": {"match_all": {}},
|
||||
"size": 50,
|
||||
"pit": {"id": pit_id, "keep_alive": "1m"},
|
||||
"sort": [{"channel_name.keyword": {"order": "asc"}}],
|
||||
}
|
||||
query_str = json.dumps(data)
|
||||
url = self.es_url + "/_search"
|
||||
all_channels = []
|
||||
while True:
|
||||
response = requests.get(url, data=query_str, headers=headers)
|
||||
json_data = json.loads(response.text)
|
||||
all_hits = json_data["hits"]["hits"]
|
||||
if all_hits:
|
||||
for hit in all_hits:
|
||||
source = hit["_source"]
|
||||
search_after = hit["sort"]
|
||||
all_channels.append(source)
|
||||
# update search_after with last hit data
|
||||
data["search_after"] = search_after
|
||||
query_str = json.dumps(data)
|
||||
else:
|
||||
break
|
||||
# clean up PIT
|
||||
query_str = json.dumps({"id": pit_id})
|
||||
requests.delete(self.es_url + "/_pit", data=query_str, headers=headers)
|
||||
return all_channels
|
||||
|
||||
def get_last_youtube_videos(self, channel_id, limit=True):
|
||||
"""get a list of last videos from channel"""
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/videos"
|
||||
obs = {
|
||||
"default_search": "ytsearch",
|
||||
"quiet": True,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
}
|
||||
if limit:
|
||||
obs["playlistend"] = self.channel_size
|
||||
chan = youtube_dl.YoutubeDL(obs).extract_info(url, download=False)
|
||||
last_videos = [(i["id"], i["title"]) for i in chan["entries"]]
|
||||
return last_videos
|
||||
|
||||
def find_missing(self):
|
||||
"""add missing videos from subscribed channels to pending"""
|
||||
all_channels = self.get_channels()
|
||||
pending_handler = PendingList()
|
||||
all_pending, all_ignore = pending_handler.get_all_pending()
|
||||
all_ids = [i["youtube_id"] for i in all_ignore + all_pending]
|
||||
all_downloaded = pending_handler.get_all_downloaded()
|
||||
to_ignore = all_ids + all_downloaded
|
||||
missing_videos = []
|
||||
counter = 1
|
||||
for channel in all_channels:
|
||||
channel_id = channel["channel_id"]
|
||||
last_videos = self.get_last_youtube_videos(channel_id)
|
||||
RedisArchivist().set_message(
|
||||
"progress:download",
|
||||
{
|
||||
"status": "rescan",
|
||||
"level": "info",
|
||||
"title": "Rescanning: Looking for new videos.",
|
||||
"message": f"Progress: {counter}/{len(all_channels)}",
|
||||
},
|
||||
)
|
||||
for video in last_videos:
|
||||
youtube_id = video[0]
|
||||
if youtube_id not in to_ignore:
|
||||
missing_videos.append(youtube_id)
|
||||
counter = counter + 1
|
||||
|
||||
return missing_videos
|
||||
|
||||
def change_subscribe(self, channel_id, channel_subscribed):
|
||||
"""subscribe or unsubscribe from channel and update"""
|
||||
if not isinstance(channel_subscribed, bool):
|
||||
print("invalid status, should be bool")
|
||||
return
|
||||
headers = {"Content-type": "application/json"}
|
||||
channel_handler = YoutubeChannel(channel_id)
|
||||
channel_dict = channel_handler.channel_dict
|
||||
channel_dict["channel_subscribed"] = channel_subscribed
|
||||
if channel_subscribed:
|
||||
# handle subscribe
|
||||
url = self.es_url + "/ta_channel/_doc/" + channel_id
|
||||
payload = json.dumps(channel_dict)
|
||||
print(channel_dict)
|
||||
else:
|
||||
url = self.es_url + "/ta_channel/_update/" + channel_id
|
||||
payload = json.dumps({"doc": channel_dict})
|
||||
# update channel
|
||||
request = requests.post(url, data=payload, headers=headers)
|
||||
if not request.ok:
|
||||
print(request.text)
|
||||
# sync to videos
|
||||
channel_handler.sync_to_videos()
|
||||
if channel_handler.source == "scraped":
|
||||
channel_handler.get_channel_art()
|
||||
|
||||
|
||||
def playlist_extractor(playlist_id):
|
||||
"""return youtube_ids from a playlist_id"""
|
||||
url = "https://www.youtube.com/playlist?list=" + playlist_id
|
||||
obs = {
|
||||
"default_search": "ytsearch",
|
||||
"quiet": True,
|
||||
"ignoreerrors": True,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
}
|
||||
playlist = youtube_dl.YoutubeDL(obs).extract_info(url, download=False)
|
||||
playlist_vids = [(i["id"], i["title"]) for i in playlist["entries"]]
|
||||
return playlist_vids
|
||||
|
||||
|
||||
class VideoDownloader:
|
||||
"""
|
||||
handle the video download functionality
|
||||
if not initiated with list, take from queue
|
||||
"""
|
||||
|
||||
def __init__(self, youtube_id_list=False):
|
||||
self.youtube_id_list = youtube_id_list
|
||||
self.config = AppConfig().config
|
||||
|
||||
def run_queue(self):
|
||||
"""setup download queue in redis loop until no more items"""
|
||||
queue = RedisQueue("dl_queue")
|
||||
|
||||
limit_queue = self.config["downloads"]["limit_count"]
|
||||
if limit_queue:
|
||||
queue.trim(limit_queue - 1)
|
||||
|
||||
while True:
|
||||
youtube_id = queue.get_next()
|
||||
if not youtube_id:
|
||||
break
|
||||
|
||||
try:
|
||||
self.dl_single_vid(youtube_id)
|
||||
except youtube_dl.utils.DownloadError:
|
||||
print("failed to download " + youtube_id)
|
||||
continue
|
||||
vid_dict = index_new_video(youtube_id)
|
||||
self.move_to_archive(vid_dict)
|
||||
self.delete_from_pending(youtube_id)
|
||||
|
||||
@staticmethod
|
||||
def add_pending():
|
||||
"""add pending videos to download queue"""
|
||||
all_pending, _ = PendingList().get_all_pending()
|
||||
to_add = [i["youtube_id"] for i in all_pending]
|
||||
if not to_add:
|
||||
# there is nothing pending
|
||||
print("download queue is empty")
|
||||
mess_dict = {
|
||||
"status": "downloading",
|
||||
"level": "error",
|
||||
"title": "Download queue is empty",
|
||||
"message": "",
|
||||
}
|
||||
RedisArchivist().set_message("progress:download", mess_dict)
|
||||
return
|
||||
|
||||
queue = RedisQueue("dl_queue")
|
||||
queue.add_list(to_add)
|
||||
|
||||
@staticmethod
|
||||
def progress_hook(response):
|
||||
"""process the progress_hooks from youtube_dl"""
|
||||
# title
|
||||
filename = response["filename"][12:].replace("_", " ")
|
||||
title = "Downloading: " + os.path.split(filename)[-1]
|
||||
# message
|
||||
try:
|
||||
percent = response["_percent_str"]
|
||||
size = response["_total_bytes_str"]
|
||||
speed = response["_speed_str"]
|
||||
eta = response["_eta_str"]
|
||||
message = f"{percent} of {size} at {speed} - time left: {eta}"
|
||||
except KeyError:
|
||||
message = ""
|
||||
mess_dict = {
|
||||
"status": "downloading",
|
||||
"level": "info",
|
||||
"title": title,
|
||||
"message": message,
|
||||
}
|
||||
RedisArchivist().set_message("progress:download", mess_dict)
|
||||
|
||||
def build_obs(self):
|
||||
"""build obs dictionary for yt-dlp"""
|
||||
obs = {
|
||||
"default_search": "ytsearch",
|
||||
"merge_output_format": "mp4",
|
||||
"restrictfilenames": True,
|
||||
"outtmpl": (
|
||||
self.config["application"]["cache_dir"]
|
||||
+ "/download/"
|
||||
+ self.config["application"]["file_template"]
|
||||
),
|
||||
"progress_hooks": [self.progress_hook],
|
||||
"noprogress": True,
|
||||
"quiet": True,
|
||||
"continuedl": True,
|
||||
"retries": 3,
|
||||
"writethumbnail": False,
|
||||
}
|
||||
if self.config["downloads"]["format"]:
|
||||
obs["format"] = self.config["downloads"]["format"]
|
||||
if self.config["downloads"]["limit_speed"]:
|
||||
obs["ratelimit"] = self.config["downloads"]["limit_speed"] * 1024
|
||||
external = False
|
||||
if external:
|
||||
obs["external_downloader"] = "aria2c"
|
||||
|
||||
postprocessors = []
|
||||
|
||||
if self.config["downloads"]["add_metadata"]:
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "FFmpegMetadata",
|
||||
"add_chapters": True,
|
||||
"add_metadata": True,
|
||||
}
|
||||
)
|
||||
|
||||
if self.config["downloads"]["add_thumbnail"]:
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "EmbedThumbnail",
|
||||
"already_have_thumbnail": True,
|
||||
}
|
||||
)
|
||||
obs["writethumbnail"] = True
|
||||
|
||||
obs["postprocessors"] = postprocessors
|
||||
|
||||
return obs
|
||||
|
||||
def dl_single_vid(self, youtube_id):
|
||||
"""download single video"""
|
||||
dl_cache = self.config["application"]["cache_dir"] + "/download/"
|
||||
obs = self.build_obs()
|
||||
|
||||
# check if already in cache to continue from there
|
||||
all_cached = ignore_filelist(os.listdir(dl_cache))
|
||||
for file_name in all_cached:
|
||||
if youtube_id in file_name:
|
||||
obs["outtmpl"] = os.path.join(dl_cache, file_name)
|
||||
with youtube_dl.YoutubeDL(obs) as ydl:
|
||||
try:
|
||||
ydl.download([youtube_id])
|
||||
except youtube_dl.utils.DownloadError:
|
||||
print("retry failed download: " + youtube_id)
|
||||
sleep(10)
|
||||
ydl.download([youtube_id])
|
||||
|
||||
if obs["writethumbnail"]:
|
||||
# webp files don't get cleaned up automatically
|
||||
all_cached = ignore_filelist(os.listdir(dl_cache))
|
||||
to_clean = [i for i in all_cached if not i.endswith(".mp4")]
|
||||
for file_name in to_clean:
|
||||
file_path = os.path.join(dl_cache, file_name)
|
||||
os.remove(file_path)
|
||||
|
||||
def move_to_archive(self, vid_dict):
|
||||
"""move downloaded video from cache to archive"""
|
||||
videos = self.config["application"]["videos"]
|
||||
host_uid = self.config["application"]["HOST_UID"]
|
||||
host_gid = self.config["application"]["HOST_GID"]
|
||||
channel_name = clean_string(vid_dict["channel"]["channel_name"])
|
||||
# make archive folder with correct permissions
|
||||
new_folder = os.path.join(videos, channel_name)
|
||||
if not os.path.exists(new_folder):
|
||||
os.makedirs(new_folder)
|
||||
os.chown(new_folder, host_uid, host_gid)
|
||||
# find real filename
|
||||
cache_dir = self.config["application"]["cache_dir"]
|
||||
all_cached = ignore_filelist(os.listdir(cache_dir + "/download/"))
|
||||
for file_str in all_cached:
|
||||
if vid_dict["youtube_id"] in file_str:
|
||||
old_file = file_str
|
||||
old_file_path = os.path.join(cache_dir, "download", old_file)
|
||||
new_file_path = os.path.join(videos, vid_dict["media_url"])
|
||||
# move media file and fix permission
|
||||
shutil.move(old_file_path, new_file_path)
|
||||
os.chown(new_file_path, host_uid, host_gid)
|
||||
|
||||
def delete_from_pending(self, youtube_id):
|
||||
"""delete downloaded video from pending index if its there"""
|
||||
es_url = self.config["application"]["es_url"]
|
||||
url = f"{es_url}/ta_download/_doc/{youtube_id}"
|
||||
response = requests.delete(url)
|
||||
if not response.ok and not response.status_code == 404:
|
||||
print(response.text)
|
||||
0
tubearchivist/home/src/download/__init__.py
Normal file
363
tubearchivist/home/src/download/queue.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Functionality:
|
||||
- handle download queue
|
||||
- linked with ta_dowload index
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from home.src.download.subscriptions import (
|
||||
ChannelSubscription,
|
||||
PlaylistSubscription,
|
||||
)
|
||||
from home.src.download.thumbnails import ThumbManager
|
||||
from home.src.download.yt_dlp_base import YtWrap
|
||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||
from home.src.index.playlist import YoutubePlaylist
|
||||
from home.src.index.video_constants import VideoTypeEnum
|
||||
from home.src.ta.config import AppConfig
|
||||
from home.src.ta.helper import get_duration_str, is_shorts
|
||||
|
||||
|
||||
class PendingIndex:
|
||||
"""base class holding all export methods"""
|
||||
|
||||
def __init__(self):
|
||||
self.all_pending = False
|
||||
self.all_ignored = False
|
||||
self.all_videos = False
|
||||
self.all_channels = False
|
||||
self.channel_overwrites = False
|
||||
self.video_overwrites = False
|
||||
self.to_skip = False
|
||||
|
||||
def get_download(self):
|
||||
"""get a list of all pending videos in ta_download"""
|
||||
data = {
|
||||
"query": {"match_all": {}},
|
||||
"sort": [{"timestamp": {"order": "asc"}}],
|
||||
}
|
||||
all_results = IndexPaginate("ta_download", data).get_results()
|
||||
|
||||
self.all_pending = []
|
||||
self.all_ignored = []
|
||||
self.to_skip = []
|
||||
|
||||
for result in all_results:
|
||||
self.to_skip.append(result["youtube_id"])
|
||||
if result["status"] == "pending":
|
||||
self.all_pending.append(result)
|
||||
elif result["status"] == "ignore":
|
||||
self.all_ignored.append(result)
|
||||
|
||||
def get_indexed(self):
|
||||
"""get a list of all videos indexed"""
|
||||
data = {
|
||||
"query": {"match_all": {}},
|
||||
"sort": [{"published": {"order": "desc"}}],
|
||||
}
|
||||
self.all_videos = IndexPaginate("ta_video", data).get_results()
|
||||
for video in self.all_videos:
|
||||
self.to_skip.append(video["youtube_id"])
|
||||
|
||||
def get_channels(self):
|
||||
"""get a list of all channels indexed"""
|
||||
self.all_channels = []
|
||||
self.channel_overwrites = {}
|
||||
data = {
|
||||
"query": {"match_all": {}},
|
||||
"sort": [{"channel_id": {"order": "asc"}}],
|
||||
}
|
||||
channels = IndexPaginate("ta_channel", data).get_results()
|
||||
|
||||
for channel in channels:
|
||||
channel_id = channel["channel_id"]
|
||||
self.all_channels.append(channel_id)
|
||||
if channel.get("channel_overwrites"):
|
||||
self.channel_overwrites.update(
|
||||
{channel_id: channel.get("channel_overwrites")}
|
||||
)
|
||||
|
||||
self._map_overwrites()
|
||||
|
||||
def _map_overwrites(self):
|
||||
"""map video ids to channel ids overwrites"""
|
||||
self.video_overwrites = {}
|
||||
for video in self.all_pending:
|
||||
video_id = video["youtube_id"]
|
||||
channel_id = video["channel_id"]
|
||||
overwrites = self.channel_overwrites.get(channel_id, False)
|
||||
if overwrites:
|
||||
self.video_overwrites.update({video_id: overwrites})
|
||||
|
||||
|
||||
class PendingInteract:
|
||||
"""interact with items in download queue"""
|
||||
|
||||
def __init__(self, youtube_id=False, status=False):
|
||||
self.youtube_id = youtube_id
|
||||
self.status = status
|
||||
|
||||
def delete_item(self):
|
||||
"""delete single item from pending"""
|
||||
path = f"ta_download/_doc/{self.youtube_id}"
|
||||
_, _ = ElasticWrap(path).delete(refresh=True)
|
||||
|
||||
def delete_by_status(self):
|
||||
"""delete all matching item by status"""
|
||||
data = {"query": {"term": {"status": {"value": self.status}}}}
|
||||
path = "ta_download/_delete_by_query"
|
||||
_, _ = ElasticWrap(path).post(data=data)
|
||||
|
||||
def update_status(self):
|
||||
"""update status of pending item"""
|
||||
if self.status == "priority":
|
||||
data = {
|
||||
"doc": {
|
||||
"status": "pending",
|
||||
"auto_start": True,
|
||||
"message": None,
|
||||
}
|
||||
}
|
||||
else:
|
||||
data = {"doc": {"status": self.status}}
|
||||
|
||||
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
|
||||
_, _ = ElasticWrap(path).post(data=data)
|
||||
|
||||
def get_item(self):
|
||||
"""return pending item dict"""
|
||||
path = f"ta_download/_doc/{self.youtube_id}"
|
||||
response, status_code = ElasticWrap(path).get()
|
||||
return response["_source"], status_code
|
||||
|
||||
def get_channel(self):
|
||||
"""
|
||||
get channel metadata from queue to not depend on channel to be indexed
|
||||
"""
|
||||
data = {
|
||||
"size": 1,
|
||||
"query": {"term": {"channel_id": {"value": self.youtube_id}}},
|
||||
}
|
||||
response, _ = ElasticWrap("ta_download/_search").get(data=data)
|
||||
hits = response["hits"]["hits"]
|
||||
if not hits:
|
||||
channel_name = "NA"
|
||||
else:
|
||||
channel_name = hits[0]["_source"].get("channel_name", "NA")
|
||||
|
||||
return {
|
||||
"channel_id": self.youtube_id,
|
||||
"channel_name": channel_name,
|
||||
}
|
||||
|
||||
|
||||
class PendingList(PendingIndex):
|
||||
"""manage the pending videos list"""
|
||||
|
||||
yt_obs = {
|
||||
"noplaylist": True,
|
||||
"writethumbnail": True,
|
||||
"simulate": True,
|
||||
"check_formats": None,
|
||||
}
|
||||
|
||||
def __init__(self, youtube_ids=False, task=False):
|
||||
super().__init__()
|
||||
self.config = AppConfig().config
|
||||
self.youtube_ids = youtube_ids
|
||||
self.task = task
|
||||
self.to_skip = False
|
||||
self.missing_videos = False
|
||||
|
||||
def parse_url_list(self):
|
||||
"""extract youtube ids from list"""
|
||||
self.missing_videos = []
|
||||
self.get_download()
|
||||
self.get_indexed()
|
||||
total = len(self.youtube_ids)
|
||||
for idx, entry in enumerate(self.youtube_ids):
|
||||
self._process_entry(entry)
|
||||
if not self.task:
|
||||
continue
|
||||
|
||||
self.task.send_progress(
|
||||
message_lines=[f"Extracting items {idx + 1}/{total}"],
|
||||
progress=(idx + 1) / total,
|
||||
)
|
||||
|
||||
def _process_entry(self, entry):
|
||||
"""process single entry from url list"""
|
||||
vid_type = self._get_vid_type(entry)
|
||||
if entry["type"] == "video":
|
||||
self._add_video(entry["url"], vid_type)
|
||||
elif entry["type"] == "channel":
|
||||
self._parse_channel(entry["url"], vid_type)
|
||||
elif entry["type"] == "playlist":
|
||||
self._parse_playlist(entry["url"])
|
||||
PlaylistSubscription().process_url_str([entry], subscribed=False)
|
||||
else:
|
||||
raise ValueError(f"invalid url_type: {entry}")
|
||||
|
||||
@staticmethod
|
||||
def _get_vid_type(entry):
|
||||
"""add vid type enum if available"""
|
||||
vid_type_str = entry.get("vid_type")
|
||||
if not vid_type_str:
|
||||
return VideoTypeEnum.UNKNOWN
|
||||
|
||||
return VideoTypeEnum(vid_type_str)
|
||||
|
||||
def _add_video(self, url, vid_type):
|
||||
"""add video to list"""
|
||||
if url not in self.missing_videos and url not in self.to_skip:
|
||||
self.missing_videos.append((url, vid_type))
|
||||
else:
|
||||
print(f"{url}: skipped adding already indexed video to download.")
|
||||
|
||||
def _parse_channel(self, url, vid_type):
|
||||
"""add all videos of channel to list"""
|
||||
video_results = ChannelSubscription().get_last_youtube_videos(
|
||||
url, limit=False, query_filter=vid_type
|
||||
)
|
||||
for video_id, _, vid_type in video_results:
|
||||
self._add_video(video_id, vid_type)
|
||||
|
||||
def _parse_playlist(self, url):
|
||||
"""add all videos of playlist to list"""
|
||||
playlist = YoutubePlaylist(url)
|
||||
playlist.build_json()
|
||||
if not playlist.json_data:
|
||||
message = f"{playlist.youtube_id}: failed to extract metadata"
|
||||
print(message)
|
||||
raise ValueError(message)
|
||||
|
||||
video_results = playlist.json_data.get("playlist_entries")
|
||||
youtube_ids = [i["youtube_id"] for i in video_results]
|
||||
for video_id in youtube_ids:
|
||||
# match vid_type later
|
||||
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
|
||||
|
||||
def add_to_pending(self, status="pending", auto_start=False):
|
||||
"""add missing videos to pending list"""
|
||||
self.get_channels()
|
||||
bulk_list = []
|
||||
|
||||
total = len(self.missing_videos)
|
||||
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
|
||||
if self.task and self.task.is_stopped():
|
||||
break
|
||||
|
||||
print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue")
|
||||
self._notify_add(idx, total)
|
||||
video_details = self.get_youtube_details(youtube_id, vid_type)
|
||||
if not video_details:
|
||||
continue
|
||||
|
||||
video_details.update(
|
||||
{
|
||||
"status": status,
|
||||
"auto_start": auto_start,
|
||||
}
|
||||
)
|
||||
|
||||
action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
|
||||
bulk_list.append(json.dumps(action))
|
||||
bulk_list.append(json.dumps(video_details))
|
||||
|
||||
url = video_details["vid_thumb_url"]
|
||||
ThumbManager(youtube_id).download_video_thumb(url)
|
||||
|
||||
if len(bulk_list) >= 20:
|
||||
self._ingest_bulk(bulk_list)
|
||||
bulk_list = []
|
||||
|
||||
self._ingest_bulk(bulk_list)
|
||||
|
||||
def _ingest_bulk(self, bulk_list):
|
||||
"""add items to queue in bulk"""
|
||||
if not bulk_list:
|
||||
return
|
||||
|
||||
# add last newline
|
||||
bulk_list.append("\n")
|
||||
query_str = "\n".join(bulk_list)
|
||||
_, _ = ElasticWrap("_bulk?refresh=true").post(query_str, ndjson=True)
|
||||
|
||||
def _notify_add(self, idx, total):
|
||||
"""send notification for adding videos to download queue"""
|
||||
if not self.task:
|
||||
return
|
||||
|
||||
self.task.send_progress(
|
||||
message_lines=[
|
||||
"Adding new videos to download queue.",
|
||||
f"Extracting items {idx + 1}/{total}",
|
||||
],
|
||||
progress=(idx + 1) / total,
|
||||
)
|
||||
|
||||
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
|
||||
"""get details from youtubedl for single pending video"""
|
||||
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
|
||||
if not vid:
|
||||
return False
|
||||
|
||||
if vid.get("id") != youtube_id:
|
||||
# skip premium videos with different id
|
||||
print(f"{youtube_id}: skipping premium video, id not matching")
|
||||
return False
|
||||
# stop if video is streaming live now
|
||||
if vid["live_status"] in ["is_upcoming", "is_live"]:
|
||||
print(f"{youtube_id}: skip is_upcoming or is_live")
|
||||
return False
|
||||
|
||||
if vid["live_status"] == "was_live":
|
||||
vid_type = VideoTypeEnum.STREAMS
|
||||
else:
|
||||
if self._check_shorts(vid):
|
||||
vid_type = VideoTypeEnum.SHORTS
|
||||
else:
|
||||
vid_type = VideoTypeEnum.VIDEOS
|
||||
|
||||
return self._parse_youtube_details(vid, vid_type)
|
||||
|
||||
@staticmethod
|
||||
def _check_shorts(vid):
|
||||
"""check if vid is shorts video"""
|
||||
if vid["width"] > vid["height"]:
|
||||
return False
|
||||
|
||||
duration = vid.get("duration")
|
||||
if duration and isinstance(duration, int):
|
||||
if duration > 60:
|
||||
return False
|
||||
|
||||
return is_shorts(vid["id"])
|
||||
|
||||
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
|
||||
"""parse response"""
|
||||
vid_id = vid.get("id")
|
||||
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
|
||||
# build dict
|
||||
youtube_details = {
|
||||
"youtube_id": vid_id,
|
||||
"channel_name": vid["channel"],
|
||||
"vid_thumb_url": vid["thumbnail"],
|
||||
"title": vid["title"],
|
||||
"channel_id": vid["channel_id"],
|
||||
"duration": get_duration_str(vid["duration"]),
|
||||
"published": published,
|
||||
"timestamp": int(datetime.now().timestamp()),
|
||||
# Pulling enum value out so it is serializable
|
||||
"vid_type": vid_type.value,
|
||||
}
|
||||
if self.all_channels:
|
||||
youtube_details.update(
|
||||
{"channel_indexed": vid["channel_id"] in self.all_channels}
|
||||
)
|
||||
return youtube_details
|
||||
389
tubearchivist/home/src/download/subscriptions.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Functionality:
|
||||
- handle channel subscriptions
|
||||
- handle playlist subscriptions
|
||||
"""
|
||||
|
||||
from home.src.download import queue # partial import
|
||||
from home.src.download.thumbnails import ThumbManager
|
||||
from home.src.download.yt_dlp_base import YtWrap
|
||||
from home.src.es.connect import IndexPaginate
|
||||
from home.src.index.channel import YoutubeChannel
|
||||
from home.src.index.playlist import YoutubePlaylist
|
||||
from home.src.index.video_constants import VideoTypeEnum
|
||||
from home.src.ta.config import AppConfig
|
||||
from home.src.ta.urlparser import Parser
|
||||
|
||||
|
||||
class ChannelSubscription:
|
||||
"""manage the list of channels subscribed"""
|
||||
|
||||
def __init__(self, task=False):
|
||||
self.config = AppConfig().config
|
||||
self.task = task
|
||||
|
||||
@staticmethod
|
||||
def get_channels(subscribed_only=True):
|
||||
"""get a list of all channels subscribed to"""
|
||||
data = {
|
||||
"sort": [{"channel_name.keyword": {"order": "asc"}}],
|
||||
}
|
||||
if subscribed_only:
|
||||
data["query"] = {"term": {"channel_subscribed": {"value": True}}}
|
||||
else:
|
||||
data["query"] = {"match_all": {}}
|
||||
|
||||
all_channels = IndexPaginate("ta_channel", data).get_results()
|
||||
|
||||
return all_channels
|
||||
|
||||
def get_last_youtube_videos(
|
||||
self, channel_id, limit=True, query_filter=VideoTypeEnum.UNKNOWN
|
||||
):
|
||||
"""get a list of last videos from channel"""
|
||||
queries = self._build_queries(query_filter, limit)
|
||||
|
||||
last_videos = []
|
||||
|
||||
for vid_type_enum, limit_amount in queries:
|
||||
obs = {
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
}
|
||||
if limit:
|
||||
obs["playlistend"] = limit_amount
|
||||
|
||||
vid_type = vid_type_enum.value
|
||||
channel = YtWrap(obs, self.config).extract(
|
||||
f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
||||
)
|
||||
if not channel:
|
||||
continue
|
||||
last_videos.extend(
|
||||
[(i["id"], i["title"], vid_type) for i in channel["entries"]]
|
||||
)
|
||||
|
||||
return last_videos
|
||||
|
||||
def _build_queries(self, query_filter, limit):
|
||||
"""build query list for vid_type"""
|
||||
limit_map = {
|
||||
"videos": self.config["subscriptions"]["channel_size"],
|
||||
"streams": self.config["subscriptions"]["live_channel_size"],
|
||||
"shorts": self.config["subscriptions"]["shorts_channel_size"],
|
||||
}
|
||||
|
||||
queries = []
|
||||
|
||||
if query_filter and query_filter.value != "unknown":
|
||||
if limit:
|
||||
query_limit = limit_map.get(query_filter.value)
|
||||
else:
|
||||
query_limit = False
|
||||
|
||||
queries.append((query_filter, query_limit))
|
||||
|
||||
return queries
|
||||
|
||||
for query_item, default_limit in limit_map.items():
|
||||
if not default_limit:
|
||||
# is deactivated in config
|
||||
continue
|
||||
|
||||
if limit:
|
||||
query_limit = default_limit
|
||||
else:
|
||||
query_limit = False
|
||||
|
||||
queries.append((VideoTypeEnum(query_item), query_limit))
|
||||
|
||||
return queries
|
||||
|
||||
def find_missing(self):
|
||||
"""add missing videos from subscribed channels to pending"""
|
||||
all_channels = self.get_channels()
|
||||
if not all_channels:
|
||||
return False
|
||||
|
||||
pending = queue.PendingList()
|
||||
pending.get_download()
|
||||
pending.get_indexed()
|
||||
|
||||
missing_videos = []
|
||||
|
||||
total = len(all_channels)
|
||||
for idx, channel in enumerate(all_channels):
|
||||
channel_id = channel["channel_id"]
|
||||
print(f"{channel_id}: find missing videos.")
|
||||
last_videos = self.get_last_youtube_videos(channel_id)
|
||||
|
||||
if last_videos:
|
||||
for video_id, _, vid_type in last_videos:
|
||||
if video_id not in pending.to_skip:
|
||||
missing_videos.append((video_id, vid_type))
|
||||
|
||||
if not self.task:
|
||||
continue
|
||||
|
||||
if self.task:
|
||||
if self.task.is_stopped():
|
||||
self.task.send_progress(["Received Stop signal."])
|
||||
break
|
||||
|
||||
self.task.send_progress(
|
||||
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
|
||||
progress=(idx + 1) / total,
|
||||
)
|
||||
|
||||
return missing_videos
|
||||
|
||||
@staticmethod
|
||||
def change_subscribe(channel_id, channel_subscribed):
|
||||
"""subscribe or unsubscribe from channel and update"""
|
||||
channel = YoutubeChannel(channel_id)
|
||||
channel.build_json()
|
||||
channel.json_data["channel_subscribed"] = channel_subscribed
|
||||
channel.upload_to_es()
|
||||
channel.sync_to_videos()
|
||||
|
||||
|
||||
class PlaylistSubscription:
|
||||
"""manage the playlist download functionality"""
|
||||
|
||||
def __init__(self, task=False):
|
||||
self.config = AppConfig().config
|
||||
self.task = task
|
||||
|
||||
@staticmethod
|
||||
def get_playlists(subscribed_only=True):
|
||||
"""get a list of all active playlists"""
|
||||
data = {
|
||||
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
|
||||
}
|
||||
data["query"] = {
|
||||
"bool": {"must": [{"term": {"playlist_active": {"value": True}}}]}
|
||||
}
|
||||
if subscribed_only:
|
||||
data["query"]["bool"]["must"].append(
|
||||
{"term": {"playlist_subscribed": {"value": True}}}
|
||||
)
|
||||
|
||||
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||
|
||||
return all_playlists
|
||||
|
||||
def process_url_str(self, new_playlists, subscribed=True):
|
||||
"""process playlist subscribe form url_str"""
|
||||
data = {"query": {"match_all": {}}, "_source": ["youtube_id"]}
|
||||
all_indexed = IndexPaginate("ta_video", data).get_results()
|
||||
all_youtube_ids = [i["youtube_id"] for i in all_indexed]
|
||||
|
||||
for idx, playlist in enumerate(new_playlists):
|
||||
playlist_id = playlist["url"]
|
||||
if not playlist["type"] == "playlist":
|
||||
print(f"{playlist_id} not a playlist, skipping...")
|
||||
continue
|
||||
|
||||
playlist_h = YoutubePlaylist(playlist_id)
|
||||
playlist_h.all_youtube_ids = all_youtube_ids
|
||||
playlist_h.build_json()
|
||||
if not playlist_h.json_data:
|
||||
message = f"{playlist_h.youtube_id}: failed to extract data"
|
||||
print(message)
|
||||
raise ValueError(message)
|
||||
|
||||
playlist_h.json_data["playlist_subscribed"] = subscribed
|
||||
playlist_h.upload_to_es()
|
||||
playlist_h.add_vids_to_playlist()
|
||||
self.channel_validate(playlist_h.json_data["playlist_channel_id"])
|
||||
|
||||
url = playlist_h.json_data["playlist_thumbnail"]
|
||||
thumb = ThumbManager(playlist_id, item_type="playlist")
|
||||
thumb.download_playlist_thumb(url)
|
||||
|
||||
if self.task:
|
||||
self.task.send_progress(
|
||||
message_lines=[
|
||||
f"Processing {idx + 1} of {len(new_playlists)}"
|
||||
],
|
||||
progress=(idx + 1) / len(new_playlists),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def channel_validate(channel_id):
|
||||
"""make sure channel of playlist is there"""
|
||||
channel = YoutubeChannel(channel_id)
|
||||
channel.build_json(upload=True)
|
||||
|
||||
@staticmethod
|
||||
def change_subscribe(playlist_id, subscribe_status):
|
||||
"""change the subscribe status of a playlist"""
|
||||
playlist = YoutubePlaylist(playlist_id)
|
||||
playlist.build_json()
|
||||
playlist.json_data["playlist_subscribed"] = subscribe_status
|
||||
playlist.upload_to_es()
|
||||
|
||||
@staticmethod
|
||||
def get_to_ignore():
|
||||
"""get all youtube_ids already downloaded or ignored"""
|
||||
pending = queue.PendingList()
|
||||
pending.get_download()
|
||||
pending.get_indexed()
|
||||
|
||||
return pending.to_skip
|
||||
|
||||
def find_missing(self):
|
||||
"""find videos in subscribed playlists not downloaded yet"""
|
||||
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
|
||||
if not all_playlists:
|
||||
return False
|
||||
|
||||
to_ignore = self.get_to_ignore()
|
||||
|
||||
missing_videos = []
|
||||
total = len(all_playlists)
|
||||
for idx, playlist_id in enumerate(all_playlists):
|
||||
size_limit = self.config["subscriptions"]["channel_size"]
|
||||
playlist = YoutubePlaylist(playlist_id)
|
||||
is_active = playlist.update_playlist()
|
||||
if not is_active:
|
||||
playlist.deactivate()
|
||||
continue
|
||||
|
||||
playlist_entries = playlist.json_data["playlist_entries"]
|
||||
if size_limit:
|
||||
del playlist_entries[size_limit:]
|
||||
|
||||
all_missing = [i for i in playlist_entries if not i["downloaded"]]
|
||||
|
||||
for video in all_missing:
|
||||
youtube_id = video["youtube_id"]
|
||||
if youtube_id not in to_ignore:
|
||||
missing_videos.append(youtube_id)
|
||||
|
||||
if not self.task:
|
||||
continue
|
||||
|
||||
if self.task:
|
||||
self.task.send_progress(
|
||||
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
|
||||
progress=(idx + 1) / total,
|
||||
)
|
||||
if self.task.is_stopped():
|
||||
self.task.send_progress(["Received Stop signal."])
|
||||
break
|
||||
|
||||
return missing_videos
|
||||
|
||||
|
||||
class SubscriptionScanner:
|
||||
"""add missing videos to queue"""
|
||||
|
||||
def __init__(self, task=False):
|
||||
self.task = task
|
||||
self.missing_videos = False
|
||||
self.auto_start = AppConfig().config["subscriptions"].get("auto_start")
|
||||
|
||||
def scan(self):
|
||||
"""scan channels and playlists"""
|
||||
if self.task:
|
||||
self.task.send_progress(["Rescanning channels and playlists."])
|
||||
|
||||
self.missing_videos = []
|
||||
self.scan_channels()
|
||||
if self.task and not self.task.is_stopped():
|
||||
self.scan_playlists()
|
||||
|
||||
return self.missing_videos
|
||||
|
||||
def scan_channels(self):
|
||||
"""get missing from channels"""
|
||||
channel_handler = ChannelSubscription(task=self.task)
|
||||
missing = channel_handler.find_missing()
|
||||
if not missing:
|
||||
return
|
||||
|
||||
for vid_id, vid_type in missing:
|
||||
self.missing_videos.append(
|
||||
{"type": "video", "vid_type": vid_type, "url": vid_id}
|
||||
)
|
||||
|
||||
def scan_playlists(self):
|
||||
"""get missing from playlists"""
|
||||
playlist_handler = PlaylistSubscription(task=self.task)
|
||||
missing = playlist_handler.find_missing()
|
||||
if not missing:
|
||||
return
|
||||
|
||||
for i in missing:
|
||||
self.missing_videos.append(
|
||||
{
|
||||
"type": "video",
|
||||
"vid_type": VideoTypeEnum.VIDEOS.value,
|
||||
"url": i,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionHandler:
|
||||
"""subscribe to channels and playlists from url_str"""
|
||||
|
||||
def __init__(self, url_str, task=False):
|
||||
self.url_str = url_str
|
||||
self.task = task
|
||||
self.to_subscribe = False
|
||||
|
||||
def subscribe(self, expected_type=False):
|
||||
"""subscribe to url_str items"""
|
||||
if self.task:
|
||||
self.task.send_progress(["Processing form content."])
|
||||
self.to_subscribe = Parser(self.url_str).parse()
|
||||
|
||||
total = len(self.to_subscribe)
|
||||
for idx, item in enumerate(self.to_subscribe):
|
||||
if self.task:
|
||||
self._notify(idx, item, total)
|
||||
|
||||
self.subscribe_type(item, expected_type=expected_type)
|
||||
|
||||
def subscribe_type(self, item, expected_type):
|
||||
"""process single item"""
|
||||
if item["type"] == "playlist":
|
||||
if expected_type and expected_type != "playlist":
|
||||
raise TypeError(
|
||||
f"expected {expected_type} url but got {item.get('type')}"
|
||||
)
|
||||
|
||||
PlaylistSubscription().process_url_str([item])
|
||||
return
|
||||
|
||||
if item["type"] == "video":
|
||||
# extract channel id from video
|
||||
vid = queue.PendingList().get_youtube_details(item["url"])
|
||||
channel_id = vid["channel_id"]
|
||||
elif item["type"] == "channel":
|
||||
channel_id = item["url"]
|
||||
else:
|
||||
raise ValueError("failed to subscribe to: " + item["url"])
|
||||
|
||||
if expected_type and expected_type != "channel":
|
||||
raise TypeError(
|
||||
f"expected {expected_type} url but got {item.get('type')}"
|
||||
)
|
||||
|
||||
self._subscribe(channel_id)
|
||||
|
||||
def _subscribe(self, channel_id):
|
||||
"""subscribe to channel"""
|
||||
ChannelSubscription().change_subscribe(
|
||||
channel_id, channel_subscribed=True
|
||||
)
|
||||
|
||||
def _notify(self, idx, item, total):
|
||||
"""send notification message to redis"""
|
||||
subscribe_type = item["type"].title()
|
||||
message_lines = [
|
||||
f"Subscribe to {subscribe_type}",
|
||||
f"Progress: {idx + 1}/{total}",
|
||||
]
|
||||
self.task.send_progress(message_lines, progress=(idx + 1) / total)
|
||||
410
tubearchivist/home/src/download/thumbnails.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
functionality:
|
||||
- handle download and caching for thumbnails
|
||||
- check for missing thumbnails
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
|
||||
class ThumbManagerBase:
|
||||
"""base class for thumbnail management"""
|
||||
|
||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
|
||||
CHANNEL_DIR = os.path.join(CACHE_DIR, "channels")
|
||||
PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists")
|
||||
|
||||
def __init__(self, item_id, item_type, fallback=False):
|
||||
self.item_id = item_id
|
||||
self.item_type = item_type
|
||||
self.fallback = fallback
|
||||
|
||||
def download_raw(self, url):
|
||||
"""download thumbnail for video"""
|
||||
if not url:
|
||||
return self.get_fallback()
|
||||
|
||||
for i in range(3):
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=5)
|
||||
if response.ok:
|
||||
try:
|
||||
img = Image.open(response.raw)
|
||||
if isinstance(img, Image.Image):
|
||||
return img
|
||||
return self.get_fallback()
|
||||
|
||||
except (UnidentifiedImageError, OSError):
|
||||
print(f"failed to open thumbnail: {url}")
|
||||
return self.get_fallback()
|
||||
|
||||
if response.status_code == 404:
|
||||
return self.get_fallback()
|
||||
|
||||
except (
|
||||
requests.exceptions.RequestException,
|
||||
requests.exceptions.ReadTimeout,
|
||||
):
|
||||
print(f"{self.item_id}: retry thumbnail download {url}")
|
||||
sleep((i + 1) ** i)
|
||||
|
||||
return self.get_fallback()
|
||||
|
||||
def get_fallback(self):
|
||||
"""get fallback thumbnail if not available"""
|
||||
print(f"{self.item_id}: failed to extract thumbnail, use fallback")
|
||||
if self.fallback:
|
||||
img_raw = Image.open(self.fallback)
|
||||
return img_raw
|
||||
|
||||
app_root = EnvironmentSettings.APP_DIR
|
||||
default_map = {
|
||||
"video": os.path.join(
|
||||
app_root, "static/img/default-video-thumb.jpg"
|
||||
),
|
||||
"playlist": os.path.join(
|
||||
app_root, "static/img/default-video-thumb.jpg"
|
||||
),
|
||||
"icon": os.path.join(
|
||||
app_root, "static/img/default-channel-icon.jpg"
|
||||
),
|
||||
"banner": os.path.join(
|
||||
app_root, "static/img/default-channel-banner.jpg"
|
||||
),
|
||||
"tvart": os.path.join(
|
||||
app_root, "static/img/default-channel-art.jpg"
|
||||
),
|
||||
}
|
||||
|
||||
img_raw = Image.open(default_map[self.item_type])
|
||||
|
||||
return img_raw
|
||||
|
||||
|
||||
class ThumbManager(ThumbManagerBase):
|
||||
"""handle thumbnails related functions"""
|
||||
|
||||
def __init__(self, item_id, item_type="video", fallback=False):
|
||||
super().__init__(item_id, item_type, fallback=fallback)
|
||||
|
||||
def download(self, url):
|
||||
"""download thumbnail"""
|
||||
print(f"{self.item_id}: download {self.item_type} thumbnail")
|
||||
if self.item_type == "video":
|
||||
self.download_video_thumb(url)
|
||||
elif self.item_type == "channel":
|
||||
self.download_channel_art(url)
|
||||
elif self.item_type == "playlist":
|
||||
self.download_playlist_thumb(url)
|
||||
|
||||
def delete(self):
|
||||
"""delete thumbnail file"""
|
||||
print(f"{self.item_id}: delete {self.item_type} thumbnail")
|
||||
if self.item_type == "video":
|
||||
self.delete_video_thumb()
|
||||
elif self.item_type == "channel":
|
||||
self.delete_channel_thumb()
|
||||
elif self.item_type == "playlist":
|
||||
self.delete_playlist_thumb()
|
||||
|
||||
def download_video_thumb(self, url, skip_existing=False):
|
||||
"""pass url for video thumbnail"""
|
||||
folder_path = os.path.join(self.VIDEO_DIR, self.item_id[0].lower())
|
||||
thumb_path = self.vid_thumb_path(absolute=True)
|
||||
|
||||
if skip_existing and os.path.exists(thumb_path):
|
||||
return
|
||||
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
img_raw = self.download_raw(url)
|
||||
width, height = img_raw.size
|
||||
|
||||
if not width / height == 16 / 9:
|
||||
new_height = width / 16 * 9
|
||||
offset = (height - new_height) / 2
|
||||
img_raw = img_raw.crop((0, offset, width, height - offset))
|
||||
|
||||
img_raw.convert("RGB").save(thumb_path)
|
||||
|
||||
def vid_thumb_path(self, absolute=False, create_folder=False):
|
||||
"""build expected path for video thumbnail from youtube_id"""
|
||||
folder_name = self.item_id[0].lower()
|
||||
folder_path = os.path.join("videos", folder_name)
|
||||
thumb_path = os.path.join(folder_path, f"{self.item_id}.jpg")
|
||||
if absolute:
|
||||
thumb_path = os.path.join(self.CACHE_DIR, thumb_path)
|
||||
|
||||
if create_folder:
|
||||
folder_path = os.path.join(self.CACHE_DIR, folder_path)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
return thumb_path
|
||||
|
||||
def download_channel_art(self, urls, skip_existing=False):
|
||||
"""pass tuple of channel thumbnails"""
|
||||
channel_thumb, channel_banner, channel_tv = urls
|
||||
self._download_channel_thumb(channel_thumb, skip_existing)
|
||||
self._download_channel_banner(channel_banner, skip_existing)
|
||||
self._download_channel_tv(channel_tv, skip_existing)
|
||||
|
||||
def _download_channel_thumb(self, channel_thumb, skip_existing):
|
||||
"""download channel thumbnail"""
|
||||
|
||||
thumb_path = os.path.join(
|
||||
self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg"
|
||||
)
|
||||
self.item_type = "icon"
|
||||
|
||||
if skip_existing and os.path.exists(thumb_path):
|
||||
return
|
||||
|
||||
img_raw = self.download_raw(channel_thumb)
|
||||
img_raw.convert("RGB").save(thumb_path)
|
||||
|
||||
def _download_channel_banner(self, channel_banner, skip_existing):
|
||||
"""download channel banner"""
|
||||
|
||||
banner_path = os.path.join(
|
||||
self.CHANNEL_DIR, self.item_id + "_banner.jpg"
|
||||
)
|
||||
self.item_type = "banner"
|
||||
if skip_existing and os.path.exists(banner_path):
|
||||
return
|
||||
|
||||
img_raw = self.download_raw(channel_banner)
|
||||
img_raw.convert("RGB").save(banner_path)
|
||||
|
||||
def _download_channel_tv(self, channel_tv, skip_existing):
|
||||
"""download channel tv art"""
|
||||
art_path = os.path.join(self.CHANNEL_DIR, self.item_id + "_tvart.jpg")
|
||||
self.item_type = "tvart"
|
||||
if skip_existing and os.path.exists(art_path):
|
||||
return
|
||||
|
||||
img_raw = self.download_raw(channel_tv)
|
||||
img_raw.convert("RGB").save(art_path)
|
||||
|
||||
def download_playlist_thumb(self, url, skip_existing=False):
|
||||
"""pass thumbnail url"""
|
||||
thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
|
||||
if skip_existing and os.path.exists(thumb_path):
|
||||
return
|
||||
|
||||
img_raw = self.download_raw(url)
|
||||
img_raw.convert("RGB").save(thumb_path)
|
||||
|
||||
def delete_video_thumb(self):
|
||||
"""delete video thumbnail if exists"""
|
||||
thumb_path = self.vid_thumb_path()
|
||||
to_delete = os.path.join(self.CACHE_DIR, thumb_path)
|
||||
if os.path.exists(to_delete):
|
||||
os.remove(to_delete)
|
||||
|
||||
def delete_channel_thumb(self):
|
||||
"""delete all artwork of channel"""
|
||||
thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg")
|
||||
banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg")
|
||||
if os.path.exists(thumb):
|
||||
os.remove(thumb)
|
||||
if os.path.exists(banner):
|
||||
os.remove(banner)
|
||||
|
||||
def delete_playlist_thumb(self):
|
||||
"""delete playlist thumbnail"""
|
||||
thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
|
||||
def get_vid_base64_blur(self):
|
||||
"""return base64 encoded placeholder"""
|
||||
file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path())
|
||||
img_raw = Image.open(file_path)
|
||||
img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20))
|
||||
img_blur = img_raw.filter(ImageFilter.BLUR)
|
||||
buffer = BytesIO()
|
||||
img_blur.save(buffer, format="JPEG")
|
||||
img_data = buffer.getvalue()
|
||||
img_base64 = base64.b64encode(img_data).decode()
|
||||
data_url = f"data:image/jpg;base64,{img_base64}"
|
||||
|
||||
return data_url
|
||||
|
||||
|
||||
class ValidatorCallback:
|
||||
"""handle callback validate thumbnails page by page"""
|
||||
|
||||
def __init__(self, source, index_name, counter=0):
|
||||
self.source = source
|
||||
self.index_name = index_name
|
||||
self.counter = counter
|
||||
|
||||
def run(self):
|
||||
"""run the task for page"""
|
||||
print(f"{self.index_name}: validate artwork")
|
||||
if self.index_name == "ta_video":
|
||||
self._validate_videos()
|
||||
elif self.index_name == "ta_channel":
|
||||
self._validate_channels()
|
||||
elif self.index_name == "ta_playlist":
|
||||
self._validate_playlists()
|
||||
|
||||
def _validate_videos(self):
|
||||
"""check if video thumbnails are correct"""
|
||||
for video in self.source:
|
||||
url = video["_source"]["vid_thumb_url"]
|
||||
handler = ThumbManager(video["_source"]["youtube_id"])
|
||||
handler.download_video_thumb(url, skip_existing=True)
|
||||
|
||||
def _validate_channels(self):
|
||||
"""check if all channel artwork is there"""
|
||||
for channel in self.source:
|
||||
urls = (
|
||||
channel["_source"]["channel_thumb_url"],
|
||||
channel["_source"]["channel_banner_url"],
|
||||
channel["_source"].get("channel_tvart_url", False),
|
||||
)
|
||||
handler = ThumbManager(channel["_source"]["channel_id"])
|
||||
handler.download_channel_art(urls, skip_existing=True)
|
||||
|
||||
def _validate_playlists(self):
|
||||
"""check if all playlist artwork is there"""
|
||||
for playlist in self.source:
|
||||
url = playlist["_source"]["playlist_thumbnail"]
|
||||
handler = ThumbManager(playlist["_source"]["playlist_id"])
|
||||
handler.download_playlist_thumb(url, skip_existing=True)
|
||||
|
||||
|
||||
class ThumbValidator:
|
||||
"""validate thumbnails"""
|
||||
|
||||
INDEX = [
|
||||
{
|
||||
"data": {
|
||||
"query": {"term": {"active": {"value": True}}},
|
||||
"_source": ["vid_thumb_url", "youtube_id"],
|
||||
},
|
||||
"name": "ta_video",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"query": {"term": {"channel_active": {"value": True}}},
|
||||
"_source": {
|
||||
"excludes": ["channel_description", "channel_overwrites"]
|
||||
},
|
||||
},
|
||||
"name": "ta_channel",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"query": {"term": {"playlist_active": {"value": True}}},
|
||||
"_source": ["playlist_id", "playlist_thumbnail"],
|
||||
},
|
||||
"name": "ta_playlist",
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self, task):
|
||||
self.task = task
|
||||
|
||||
def validate(self):
|
||||
"""validate all indexes"""
|
||||
for index in self.INDEX:
|
||||
total = self._get_total(index["name"])
|
||||
if not total:
|
||||
continue
|
||||
|
||||
paginate = IndexPaginate(
|
||||
index_name=index["name"],
|
||||
data=index["data"],
|
||||
size=1000,
|
||||
callback=ValidatorCallback,
|
||||
task=self.task,
|
||||
total=total,
|
||||
)
|
||||
_ = paginate.get_results()
|
||||
|
||||
@staticmethod
|
||||
def _get_total(index_name):
|
||||
"""get total documents in index"""
|
||||
path = f"{index_name}/_count"
|
||||
response, _ = ElasticWrap(path).get()
|
||||
|
||||
return response.get("count")
|
||||
|
||||
|
||||
class ThumbFilesystem:
|
||||
"""sync thumbnail files to media files"""
|
||||
|
||||
INDEX_NAME = "ta_video"
|
||||
|
||||
def __init__(self, task=False):
|
||||
self.task = task
|
||||
|
||||
def embed(self):
|
||||
"""entry point"""
|
||||
data = {
|
||||
"query": {"match_all": {}},
|
||||
"_source": ["media_url", "youtube_id"],
|
||||
}
|
||||
paginate = IndexPaginate(
|
||||
index_name=self.INDEX_NAME,
|
||||
data=data,
|
||||
size=200,
|
||||
callback=EmbedCallback,
|
||||
task=self.task,
|
||||
total=self._get_total(),
|
||||
)
|
||||
_ = paginate.get_results()
|
||||
|
||||
def _get_total(self):
|
||||
"""get total documents in index"""
|
||||
path = f"{self.INDEX_NAME}/_count"
|
||||
response, _ = ElasticWrap(path).get()
|
||||
|
||||
return response.get("count")
|
||||
|
||||
|
||||
class EmbedCallback:
|
||||
"""callback class to embed thumbnails"""
|
||||
|
||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
|
||||
FORMAT = MP4Cover.FORMAT_JPEG
|
||||
|
||||
def __init__(self, source, index_name, counter=0):
|
||||
self.source = source
|
||||
self.index_name = index_name
|
||||
self.counter = counter
|
||||
|
||||
def run(self):
|
||||
"""run embed"""
|
||||
for video in self.source:
|
||||
video_id = video["_source"]["youtube_id"]
|
||||
media_url = os.path.join(
|
||||
self.MEDIA_DIR, video["_source"]["media_url"]
|
||||
)
|
||||
thumb_path = os.path.join(
|
||||
self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path()
|
||||
)
|
||||
if os.path.exists(thumb_path):
|
||||
self.embed(media_url, thumb_path)
|
||||
|
||||
def embed(self, media_url, thumb_path):
|
||||
"""embed thumb in single media file"""
|
||||
video = MP4(media_url)
|
||||
with open(thumb_path, "rb") as f:
|
||||
video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)]
|
||||
|
||||
video.save()
|
||||
173
tubearchivist/home/src/download/yt_dlp_base.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
functionality:
|
||||
- base class to make all calls to yt-dlp
|
||||
- handle yt-dlp errors
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from http import cookiejar
|
||||
from io import StringIO
|
||||
|
||||
import yt_dlp
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
from home.src.ta.ta_redis import RedisArchivist
|
||||
|
||||
|
||||
class YtWrap:
|
||||
"""wrap calls to yt"""
|
||||
|
||||
OBS_BASE = {
|
||||
"default_search": "ytsearch",
|
||||
"quiet": True,
|
||||
"check_formats": "selected",
|
||||
"socket_timeout": 10,
|
||||
"extractor_retries": 3,
|
||||
"retries": 10,
|
||||
}
|
||||
|
||||
def __init__(self, obs_request, config=False):
|
||||
self.obs_request = obs_request
|
||||
self.config = config
|
||||
self.build_obs()
|
||||
|
||||
def build_obs(self):
|
||||
"""build yt-dlp obs"""
|
||||
self.obs = self.OBS_BASE.copy()
|
||||
self.obs.update(self.obs_request)
|
||||
if self.config:
|
||||
self.add_cookie()
|
||||
|
||||
def add_cookie(self):
|
||||
"""add cookie if enabled"""
|
||||
if self.config["downloads"]["cookie_import"]:
|
||||
cookie_io = CookieHandler(self.config).get()
|
||||
self.obs["cookiefile"] = cookie_io
|
||||
|
||||
def download(self, url):
|
||||
"""make download request"""
|
||||
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
||||
try:
|
||||
ydl.download([url])
|
||||
except yt_dlp.utils.DownloadError as err:
|
||||
print(f"{url}: failed to download with message {err}")
|
||||
if "Temporary failure in name resolution" in str(err):
|
||||
raise ConnectionError("lost the internet, abort!") from err
|
||||
|
||||
return False, str(err)
|
||||
|
||||
return True, True
|
||||
|
||||
def extract(self, url):
|
||||
"""make extract request"""
|
||||
try:
|
||||
response = yt_dlp.YoutubeDL(self.obs).extract_info(url)
|
||||
except cookiejar.LoadError:
|
||||
print("cookie file is invalid")
|
||||
return False
|
||||
except yt_dlp.utils.ExtractorError as err:
|
||||
print(f"{url}: failed to extract with message: {err}, continue...")
|
||||
return False
|
||||
except yt_dlp.utils.DownloadError as err:
|
||||
if "This channel does not have a" in str(err):
|
||||
return False
|
||||
|
||||
print(f"{url}: failed to get info from youtube with message {err}")
|
||||
if "Temporary failure in name resolution" in str(err):
|
||||
raise ConnectionError("lost the internet, abort!") from err
|
||||
|
||||
return False
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CookieHandler:
|
||||
"""handle youtube cookie for yt-dlp"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.cookie_io = False
|
||||
self.config = config
|
||||
self.cache_dir = EnvironmentSettings.CACHE_DIR
|
||||
|
||||
def get(self):
|
||||
"""get cookie io stream"""
|
||||
cookie = RedisArchivist().get_message("cookie")
|
||||
self.cookie_io = StringIO(cookie)
|
||||
return self.cookie_io
|
||||
|
||||
def import_cookie(self):
|
||||
"""import cookie from file"""
|
||||
import_path = os.path.join(
|
||||
self.cache_dir, "import", "cookies.google.txt"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(import_path, encoding="utf-8") as cookie_file:
|
||||
cookie = cookie_file.read()
|
||||
except FileNotFoundError as err:
|
||||
print(f"cookie: {import_path} file not found")
|
||||
raise err
|
||||
|
||||
self.set_cookie(cookie)
|
||||
|
||||
os.remove(import_path)
|
||||
print("cookie: import successful")
|
||||
|
||||
def set_cookie(self, cookie):
|
||||
"""set cookie str and activate in config"""
|
||||
RedisArchivist().set_message("cookie", cookie, save=True)
|
||||
path = ".downloads.cookie_import"
|
||||
RedisArchivist().set_message("config", True, path=path, save=True)
|
||||
self.config["downloads"]["cookie_import"] = True
|
||||
print("cookie: activated and stored in Redis")
|
||||
|
||||
@staticmethod
|
||||
def revoke():
|
||||
"""revoke cookie"""
|
||||
RedisArchivist().del_message("cookie")
|
||||
RedisArchivist().del_message("cookie:valid")
|
||||
RedisArchivist().set_message(
|
||||
"config", False, path=".downloads.cookie_import"
|
||||
)
|
||||
print("cookie: revoked")
|
||||
|
||||
def validate(self):
|
||||
"""validate cookie using the liked videos playlist"""
|
||||
print("validating cookie")
|
||||
obs_request = {
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
}
|
||||
validator = YtWrap(obs_request, self.config)
|
||||
response = bool(validator.extract("LL"))
|
||||
self.store_validation(response)
|
||||
|
||||
# update in redis to avoid expiring
|
||||
modified = validator.obs["cookiefile"].getvalue()
|
||||
if modified:
|
||||
RedisArchivist().set_message("cookie", modified)
|
||||
|
||||
if not response:
|
||||
mess_dict = {
|
||||
"status": "message:download",
|
||||
"level": "error",
|
||||
"title": "Cookie validation failed, exiting...",
|
||||
"message": "",
|
||||
}
|
||||
RedisArchivist().set_message(
|
||||
"message:download", mess_dict, expire=4
|
||||
)
|
||||
print("cookie validation failed, exiting...")
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def store_validation(response):
|
||||
"""remember last validation"""
|
||||
now = datetime.now()
|
||||
message = {
|
||||
"status": response,
|
||||
"validated": int(now.timestamp()),
|
||||
"validated_str": now.strftime("%Y-%m-%d %H:%M"),
|
||||
}
|
||||
RedisArchivist().set_message("cookie:valid", message)
|
||||
423
tubearchivist/home/src/download/yt_dlp_handler.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
functionality:
|
||||
- handle yt_dlp
|
||||
- build options and post processor
|
||||
- download video files
|
||||
- move to archive
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
from home.src.download.queue import PendingList
|
||||
from home.src.download.subscriptions import PlaylistSubscription
|
||||
from home.src.download.yt_dlp_base import YtWrap
|
||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||
from home.src.index.channel import YoutubeChannel
|
||||
from home.src.index.comments import CommentList
|
||||
from home.src.index.playlist import YoutubePlaylist
|
||||
from home.src.index.video import YoutubeVideo, index_new_video
|
||||
from home.src.index.video_constants import VideoTypeEnum
|
||||
from home.src.ta.config import AppConfig
|
||||
from home.src.ta.helper import ignore_filelist
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class DownloadPostProcess:
|
||||
"""handle task to run after download queue finishes"""
|
||||
|
||||
def __init__(self, download):
|
||||
self.download = download
|
||||
self.now = int(datetime.now().timestamp())
|
||||
self.pending = False
|
||||
|
||||
def run(self):
|
||||
"""run all functions"""
|
||||
self.pending = PendingList()
|
||||
self.pending.get_download()
|
||||
self.pending.get_channels()
|
||||
self.pending.get_indexed()
|
||||
self.auto_delete_all()
|
||||
self.auto_delete_overwrites()
|
||||
self.validate_playlists()
|
||||
self.get_comments()
|
||||
|
||||
def auto_delete_all(self):
|
||||
"""handle auto delete"""
|
||||
autodelete_days = self.download.config["downloads"]["autodelete_days"]
|
||||
if not autodelete_days:
|
||||
return
|
||||
|
||||
print(f"auto delete older than {autodelete_days} days")
|
||||
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
|
||||
data = {
|
||||
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
"sort": [{"player.watched_date": {"order": "asc"}}],
|
||||
}
|
||||
self._auto_delete_watched(data)
|
||||
|
||||
def auto_delete_overwrites(self):
|
||||
"""handle per channel auto delete from overwrites"""
|
||||
for channel_id, value in self.pending.channel_overwrites.items():
|
||||
if "autodelete_days" in value:
|
||||
autodelete_days = value.get("autodelete_days")
|
||||
print(f"{channel_id}: delete older than {autodelete_days}d")
|
||||
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
|
||||
must_list = [
|
||||
{"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
{"term": {"channel.channel_id": {"value": channel_id}}},
|
||||
]
|
||||
data = {
|
||||
"query": {"bool": {"must": must_list}},
|
||||
"sort": [{"player.watched_date": {"order": "desc"}}],
|
||||
}
|
||||
self._auto_delete_watched(data)
|
||||
|
||||
@staticmethod
|
||||
def _auto_delete_watched(data):
|
||||
"""delete watched videos after x days"""
|
||||
to_delete = IndexPaginate("ta_video", data).get_results()
|
||||
if not to_delete:
|
||||
return
|
||||
|
||||
for video in to_delete:
|
||||
youtube_id = video["youtube_id"]
|
||||
print(f"{youtube_id}: auto delete video")
|
||||
YoutubeVideo(youtube_id).delete_media_file()
|
||||
|
||||
print("add deleted to ignore list")
|
||||
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
|
||||
pending = PendingList(youtube_ids=vids)
|
||||
pending.parse_url_list()
|
||||
pending.add_to_pending(status="ignore")
|
||||
|
||||
def validate_playlists(self):
|
||||
"""look for playlist needing to update"""
|
||||
for id_c, channel_id in enumerate(self.download.channels):
|
||||
channel = YoutubeChannel(channel_id, task=self.download.task)
|
||||
overwrites = self.pending.channel_overwrites.get(channel_id, False)
|
||||
if overwrites and overwrites.get("index_playlists"):
|
||||
# validate from remote
|
||||
channel.index_channel_playlists()
|
||||
continue
|
||||
|
||||
# validate from local
|
||||
playlists = channel.get_indexed_playlists(active_only=True)
|
||||
all_channel_playlist = [i["playlist_id"] for i in playlists]
|
||||
self._validate_channel_playlist(all_channel_playlist, id_c)
|
||||
|
||||
def _validate_channel_playlist(self, all_channel_playlist, id_c):
|
||||
"""scan channel for playlist needing update"""
|
||||
all_youtube_ids = [i["youtube_id"] for i in self.pending.all_videos]
|
||||
for id_p, playlist_id in enumerate(all_channel_playlist):
|
||||
playlist = YoutubePlaylist(playlist_id)
|
||||
playlist.all_youtube_ids = all_youtube_ids
|
||||
playlist.build_json(scrape=True)
|
||||
if not playlist.json_data:
|
||||
playlist.deactivate()
|
||||
continue
|
||||
|
||||
playlist.add_vids_to_playlist()
|
||||
playlist.upload_to_es()
|
||||
self._notify_playlist_progress(all_channel_playlist, id_c, id_p)
|
||||
|
||||
def _notify_playlist_progress(self, all_channel_playlist, id_c, id_p):
|
||||
"""notify to UI"""
|
||||
if not self.download.task:
|
||||
return
|
||||
|
||||
total_channel = len(self.download.channels)
|
||||
total_playlist = len(all_channel_playlist)
|
||||
|
||||
message = [
|
||||
f"Post Processing Channels: {id_c}/{total_channel}",
|
||||
f"Validate Playlists {id_p + 1}/{total_playlist}",
|
||||
]
|
||||
progress = (id_c + 1) / total_channel
|
||||
self.download.task.send_progress(message, progress=progress)
|
||||
|
||||
def get_comments(self):
|
||||
"""get comments from youtube"""
|
||||
CommentList(self.download.videos, task=self.download.task).index()
|
||||
|
||||
|
||||
class VideoDownloader:
|
||||
"""
|
||||
handle the video download functionality
|
||||
if not initiated with list, take from queue
|
||||
"""
|
||||
|
||||
def __init__(self, youtube_id_list=False, task=False):
|
||||
self.obs = False
|
||||
self.video_overwrites = False
|
||||
self.youtube_id_list = youtube_id_list
|
||||
self.task = task
|
||||
self.config = AppConfig().config
|
||||
self.cache_dir = EnvironmentSettings.CACHE_DIR
|
||||
self.media_dir = EnvironmentSettings.MEDIA_DIR
|
||||
self._build_obs()
|
||||
self.channels = set()
|
||||
self.videos = set()
|
||||
|
||||
def run_queue(self, auto_only=False):
|
||||
"""setup download queue in redis loop until no more items"""
|
||||
self._get_overwrites()
|
||||
while True:
|
||||
video_data = self._get_next(auto_only)
|
||||
if self.task.is_stopped() or not video_data:
|
||||
self._reset_auto()
|
||||
break
|
||||
|
||||
youtube_id = video_data.get("youtube_id")
|
||||
print(f"{youtube_id}: Downloading video")
|
||||
self._notify(video_data, "Validate download format")
|
||||
|
||||
success = self._dl_single_vid(youtube_id)
|
||||
if not success:
|
||||
continue
|
||||
|
||||
self._notify(video_data, "Add video metadata to index")
|
||||
|
||||
vid_dict = index_new_video(
|
||||
youtube_id,
|
||||
video_overwrites=self.video_overwrites,
|
||||
video_type=VideoTypeEnum(video_data["vid_type"]),
|
||||
)
|
||||
self.channels.add(vid_dict["channel"]["channel_id"])
|
||||
self.videos.add(vid_dict["youtube_id"])
|
||||
|
||||
self._notify(video_data, "Move downloaded file to archive")
|
||||
self.move_to_archive(vid_dict)
|
||||
self._delete_from_pending(youtube_id)
|
||||
|
||||
# post processing
|
||||
self._add_subscribed_channels()
|
||||
DownloadPostProcess(self).run()
|
||||
|
||||
return self.videos
|
||||
|
||||
def _notify(self, video_data, message):
|
||||
"""send progress notification to task"""
|
||||
if not self.task:
|
||||
return
|
||||
|
||||
typ = VideoTypeEnum(video_data["vid_type"]).value.rstrip("s").title()
|
||||
title = video_data.get("title")
|
||||
self.task.send_progress([f"Processing {typ}: {title}", message])
|
||||
|
||||
def _get_next(self, auto_only):
|
||||
"""get next item in queue"""
|
||||
must_list = [{"term": {"status": {"value": "pending"}}}]
|
||||
must_not_list = [{"exists": {"field": "message"}}]
|
||||
if auto_only:
|
||||
must_list.append({"term": {"auto_start": {"value": True}}})
|
||||
|
||||
data = {
|
||||
"size": 1,
|
||||
"query": {"bool": {"must": must_list, "must_not": must_not_list}},
|
||||
"sort": [
|
||||
{"auto_start": {"order": "desc"}},
|
||||
{"timestamp": {"order": "asc"}},
|
||||
],
|
||||
}
|
||||
path = "ta_download/_search"
|
||||
response, _ = ElasticWrap(path).get(data=data)
|
||||
if not response["hits"]["hits"]:
|
||||
return False
|
||||
|
||||
return response["hits"]["hits"][0]["_source"]
|
||||
|
||||
def _get_overwrites(self):
|
||||
"""get channel overwrites"""
|
||||
pending = PendingList()
|
||||
pending.get_download()
|
||||
pending.get_channels()
|
||||
self.video_overwrites = pending.video_overwrites
|
||||
|
||||
def _progress_hook(self, response):
|
||||
"""process the progress_hooks from yt_dlp"""
|
||||
progress = False
|
||||
try:
|
||||
size = response.get("_total_bytes_str")
|
||||
if size.strip() == "N/A":
|
||||
size = response.get("_total_bytes_estimate_str", "N/A")
|
||||
|
||||
percent = response["_percent_str"]
|
||||
progress = float(percent.strip("%")) / 100
|
||||
speed = response["_speed_str"]
|
||||
eta = response["_eta_str"]
|
||||
message = f"{percent} of {size} at {speed} - time left: {eta}"
|
||||
except KeyError:
|
||||
message = "processing"
|
||||
|
||||
if self.task:
|
||||
title = response["info_dict"]["title"]
|
||||
self.task.send_progress([title, message], progress=progress)
|
||||
|
||||
def _build_obs(self):
|
||||
"""collection to build all obs passed to yt-dlp"""
|
||||
self._build_obs_basic()
|
||||
self._build_obs_user()
|
||||
self._build_obs_postprocessors()
|
||||
|
||||
def _build_obs_basic(self):
|
||||
"""initial obs"""
|
||||
self.obs = {
|
||||
"merge_output_format": "mp4",
|
||||
"outtmpl": (self.cache_dir + "/download/%(id)s.mp4"),
|
||||
"progress_hooks": [self._progress_hook],
|
||||
"noprogress": True,
|
||||
"continuedl": True,
|
||||
"writethumbnail": False,
|
||||
"noplaylist": True,
|
||||
}
|
||||
|
||||
def _build_obs_user(self):
|
||||
"""build user customized options"""
|
||||
if self.config["downloads"]["format"]:
|
||||
self.obs["format"] = self.config["downloads"]["format"]
|
||||
if self.config["downloads"]["format_sort"]:
|
||||
format_sort = self.config["downloads"]["format_sort"]
|
||||
format_sort_list = [i.strip() for i in format_sort.split(",")]
|
||||
self.obs["format_sort"] = format_sort_list
|
||||
if self.config["downloads"]["limit_speed"]:
|
||||
self.obs["ratelimit"] = (
|
||||
self.config["downloads"]["limit_speed"] * 1024
|
||||
)
|
||||
|
||||
throttle = self.config["downloads"]["throttledratelimit"]
|
||||
if throttle:
|
||||
self.obs["throttledratelimit"] = throttle * 1024
|
||||
|
||||
def _build_obs_postprocessors(self):
|
||||
"""add postprocessor to obs"""
|
||||
postprocessors = []
|
||||
|
||||
if self.config["downloads"]["add_metadata"]:
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "FFmpegMetadata",
|
||||
"add_chapters": True,
|
||||
"add_metadata": True,
|
||||
}
|
||||
)
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "MetadataFromField",
|
||||
"formats": [
|
||||
"%(title)s:%(meta_title)s",
|
||||
"%(uploader)s:%(meta_artist)s",
|
||||
":(?P<album>)",
|
||||
],
|
||||
"when": "pre_process",
|
||||
}
|
||||
)
|
||||
|
||||
if self.config["downloads"]["add_thumbnail"]:
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "EmbedThumbnail",
|
||||
"already_have_thumbnail": True,
|
||||
}
|
||||
)
|
||||
self.obs["writethumbnail"] = True
|
||||
|
||||
self.obs["postprocessors"] = postprocessors
|
||||
|
||||
def get_format_overwrites(self, youtube_id):
|
||||
"""get overwrites from single video"""
|
||||
overwrites = self.video_overwrites.get(youtube_id, False)
|
||||
if overwrites:
|
||||
return overwrites.get("download_format", False)
|
||||
|
||||
return False
|
||||
|
||||
def _dl_single_vid(self, youtube_id):
|
||||
"""download single video"""
|
||||
obs = self.obs.copy()
|
||||
format_overwrite = self.get_format_overwrites(youtube_id)
|
||||
if format_overwrite:
|
||||
obs["format"] = format_overwrite
|
||||
|
||||
dl_cache = self.cache_dir + "/download/"
|
||||
|
||||
# check if already in cache to continue from there
|
||||
all_cached = ignore_filelist(os.listdir(dl_cache))
|
||||
for file_name in all_cached:
|
||||
if youtube_id in file_name:
|
||||
obs["outtmpl"] = os.path.join(dl_cache, file_name)
|
||||
|
||||
success, message = YtWrap(obs, self.config).download(youtube_id)
|
||||
if not success:
|
||||
self._handle_error(youtube_id, message)
|
||||
|
||||
if self.obs["writethumbnail"]:
|
||||
# webp files don't get cleaned up automatically
|
||||
all_cached = ignore_filelist(os.listdir(dl_cache))
|
||||
to_clean = [i for i in all_cached if not i.endswith(".mp4")]
|
||||
for file_name in to_clean:
|
||||
file_path = os.path.join(dl_cache, file_name)
|
||||
os.remove(file_path)
|
||||
|
||||
return success
|
||||
|
||||
@staticmethod
|
||||
def _handle_error(youtube_id, message):
|
||||
"""store error message"""
|
||||
data = {"doc": {"message": message}}
|
||||
_, _ = ElasticWrap(f"ta_download/_update/{youtube_id}").post(data=data)
|
||||
|
||||
def move_to_archive(self, vid_dict):
|
||||
"""move downloaded video from cache to archive"""
|
||||
host_uid = EnvironmentSettings.HOST_UID
|
||||
host_gid = EnvironmentSettings.HOST_GID
|
||||
# make folder
|
||||
folder = os.path.join(
|
||||
self.media_dir, vid_dict["channel"]["channel_id"]
|
||||
)
|
||||
if not os.path.exists(folder):
|
||||
os.makedirs(folder)
|
||||
if host_uid and host_gid:
|
||||
os.chown(folder, host_uid, host_gid)
|
||||
# move media file
|
||||
media_file = vid_dict["youtube_id"] + ".mp4"
|
||||
old_path = os.path.join(self.cache_dir, "download", media_file)
|
||||
new_path = os.path.join(self.media_dir, vid_dict["media_url"])
|
||||
# move media file and fix permission
|
||||
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
||||
if host_uid and host_gid:
|
||||
os.chown(new_path, host_uid, host_gid)
|
||||
|
||||
@staticmethod
|
||||
def _delete_from_pending(youtube_id):
|
||||
"""delete downloaded video from pending index if its there"""
|
||||
path = f"ta_download/_doc/{youtube_id}?refresh=true"
|
||||
_, _ = ElasticWrap(path).delete()
|
||||
|
||||
def _add_subscribed_channels(self):
|
||||
"""add all channels subscribed to refresh"""
|
||||
all_subscribed = PlaylistSubscription().get_playlists()
|
||||
if not all_subscribed:
|
||||
return
|
||||
|
||||
channel_ids = [i["playlist_channel_id"] for i in all_subscribed]
|
||||
for channel_id in channel_ids:
|
||||
self.channels.add(channel_id)
|
||||
|
||||
return
|
||||
|
||||
def _reset_auto(self):
|
||||
"""reset autostart to defaults after queue stop"""
|
||||
path = "ta_download/_update_by_query"
|
||||
data = {
|
||||
"query": {"term": {"auto_start": {"value": True}}},
|
||||
"script": {
|
||||
"source": "ctx._source.auto_start = false",
|
||||
"lang": "painless",
|
||||
},
|
||||
}
|
||||
response, _ = ElasticWrap(path).post(data=data)
|
||||
updated = response.get("updated")
|
||||
if updated:
|
||||
print(f"[download] reset auto start on {updated} videos.")
|
||||
0
tubearchivist/home/src/es/__init__.py
Normal file
267
tubearchivist/home/src/es/backup.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Functionality:
|
||||
- Handle json zip file based backup
|
||||
- create backup
|
||||
- restore backup
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||
from home.src.ta.config import AppConfig
|
||||
from home.src.ta.helper import get_mapping, ignore_filelist
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class ElasticBackup:
|
||||
"""dump index to nd-json files for later bulk import"""
|
||||
|
||||
INDEX_SPLIT = ["comment"]
|
||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
|
||||
|
||||
def __init__(self, reason=False, task=False):
|
||||
self.config = AppConfig().config
|
||||
self.timestamp = datetime.now().strftime("%Y%m%d")
|
||||
self.index_config = get_mapping()
|
||||
self.reason = reason
|
||||
self.task = task
|
||||
|
||||
def backup_all_indexes(self):
|
||||
"""backup all indexes, add reason to init"""
|
||||
print("backup all indexes")
|
||||
if not self.reason:
|
||||
raise ValueError("missing backup reason in ElasticBackup")
|
||||
|
||||
if self.task:
|
||||
self.task.send_progress(["Scanning your index."])
|
||||
for index in self.index_config:
|
||||
index_name = index["index_name"]
|
||||
print(f"backup: export in progress for {index_name}")
|
||||
if not self.index_exists(index_name):
|
||||
print(f"skip backup for not yet existing index {index_name}")
|
||||
continue
|
||||
|
||||
self.backup_index(index_name)
|
||||
|
||||
if self.task:
|
||||
self.task.send_progress(["Compress files to zip archive."])
|
||||
self.zip_it()
|
||||
if self.reason == "auto":
|
||||
self.rotate_backup()
|
||||
|
||||
def backup_index(self, index_name):
|
||||
"""export all documents of a single index"""
|
||||
paginate_kwargs = {
|
||||
"data": {"query": {"match_all": {}}},
|
||||
"keep_source": True,
|
||||
"callback": BackupCallback,
|
||||
"task": self.task,
|
||||
"total": self._get_total(index_name),
|
||||
}
|
||||
|
||||
if index_name in self.INDEX_SPLIT:
|
||||
paginate_kwargs.update({"size": 200})
|
||||
|
||||
paginate = IndexPaginate(f"ta_{index_name}", **paginate_kwargs)
|
||||
_ = paginate.get_results()
|
||||
|
||||
@staticmethod
|
||||
def _get_total(index_name):
|
||||
"""get total documents in index"""
|
||||
path = f"ta_{index_name}/_count"
|
||||
response, _ = ElasticWrap(path).get()
|
||||
|
||||
return response.get("count")
|
||||
|
||||
def zip_it(self):
|
||||
"""pack it up into single zip file"""
|
||||
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
|
||||
|
||||
to_backup = []
|
||||
for file in os.listdir(self.BACKUP_DIR):
|
||||
if file.endswith(".json"):
|
||||
to_backup.append(os.path.join(self.BACKUP_DIR, file))
|
||||
|
||||
backup_file = os.path.join(self.BACKUP_DIR, file_name)
|
||||
|
||||
comp = zipfile.ZIP_DEFLATED
|
||||
with zipfile.ZipFile(backup_file, "w", compression=comp) as zip_f:
|
||||
for backup_file in to_backup:
|
||||
zip_f.write(backup_file, os.path.basename(backup_file))
|
||||
|
||||
# cleanup
|
||||
for backup_file in to_backup:
|
||||
os.remove(backup_file)
|
||||
|
||||
def post_bulk_restore(self, file_name):
|
||||
"""send bulk to es"""
|
||||
file_path = os.path.join(self.CACHE_DIR, file_name)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
|
||||
if not data.strip():
|
||||
return
|
||||
|
||||
_, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
|
||||
|
||||
def get_all_backup_files(self):
|
||||
"""build all available backup files for view"""
|
||||
all_backup_files = ignore_filelist(os.listdir(self.BACKUP_DIR))
|
||||
all_available_backups = [
|
||||
i
|
||||
for i in all_backup_files
|
||||
if i.startswith("ta_") and i.endswith(".zip")
|
||||
]
|
||||
all_available_backups.sort(reverse=True)
|
||||
|
||||
backup_dicts = []
|
||||
for filename in all_available_backups:
|
||||
data = self.build_backup_file_data(filename)
|
||||
backup_dicts.append(data)
|
||||
|
||||
return backup_dicts
|
||||
|
||||
def build_backup_file_data(self, filename):
|
||||
"""build metadata of single backup file"""
|
||||
file_path = os.path.join(self.BACKUP_DIR, filename)
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
file_split = filename.split("-")
|
||||
if len(file_split) == 2:
|
||||
timestamp = file_split[1].strip(".zip")
|
||||
reason = False
|
||||
elif len(file_split) == 3:
|
||||
timestamp = file_split[1]
|
||||
reason = file_split[2].strip(".zip")
|
||||
|
||||
data = {
|
||||
"filename": filename,
|
||||
"file_path": file_path,
|
||||
"file_size": os.path.getsize(file_path),
|
||||
"timestamp": timestamp,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
def restore(self, filename):
|
||||
"""
|
||||
restore from backup zip file
|
||||
call reset from ElasitIndexWrap first to start blank
|
||||
"""
|
||||
zip_content = self._unpack_zip_backup(filename)
|
||||
self._restore_json_files(zip_content)
|
||||
|
||||
def _unpack_zip_backup(self, filename):
|
||||
"""extract backup zip and return filelist"""
|
||||
file_path = os.path.join(self.BACKUP_DIR, filename)
|
||||
|
||||
with zipfile.ZipFile(file_path, "r") as z:
|
||||
zip_content = z.namelist()
|
||||
z.extractall(self.BACKUP_DIR)
|
||||
|
||||
return zip_content
|
||||
|
||||
def _restore_json_files(self, zip_content):
|
||||
"""go through the unpacked files and restore"""
|
||||
for idx, json_f in enumerate(zip_content):
|
||||
self._notify_restore(idx, json_f, len(zip_content))
|
||||
file_name = os.path.join(self.BACKUP_DIR, json_f)
|
||||
|
||||
if not json_f.startswith("es_") or not json_f.endswith(".json"):
|
||||
os.remove(file_name)
|
||||
continue
|
||||
|
||||
print("restoring: " + json_f)
|
||||
self.post_bulk_restore(file_name)
|
||||
os.remove(file_name)
|
||||
|
||||
def _notify_restore(self, idx, json_f, total_files):
|
||||
"""notify restore progress"""
|
||||
message = [f"Restore index from json backup file {json_f}."]
|
||||
progress = (idx + 1) / total_files
|
||||
self.task.send_progress(message_lines=message, progress=progress)
|
||||
|
||||
@staticmethod
|
||||
def index_exists(index_name):
|
||||
"""check if index already exists to skip"""
|
||||
_, status_code = ElasticWrap(f"ta_{index_name}").get()
|
||||
exists = status_code == 200
|
||||
|
||||
return exists
|
||||
|
||||
def rotate_backup(self):
|
||||
"""delete old backups if needed"""
|
||||
rotate = self.config["scheduler"]["run_backup_rotate"]
|
||||
if not rotate:
|
||||
return
|
||||
|
||||
all_backup_files = self.get_all_backup_files()
|
||||
auto = [i for i in all_backup_files if i["reason"] == "auto"]
|
||||
|
||||
if len(auto) <= rotate:
|
||||
print("no backup files to rotate")
|
||||
return
|
||||
|
||||
all_to_delete = auto[rotate:]
|
||||
for to_delete in all_to_delete:
|
||||
self.delete_file(to_delete["filename"])
|
||||
|
||||
def delete_file(self, filename):
|
||||
"""delete backup file"""
|
||||
file_path = os.path.join(self.BACKUP_DIR, filename)
|
||||
if not os.path.exists(file_path):
|
||||
print(f"backup file not found: {filename}")
|
||||
return False
|
||||
|
||||
print(f"remove old backup file: {file_path}")
|
||||
os.remove(file_path)
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
class BackupCallback:
|
||||
"""handle backup ndjson writer as callback for IndexPaginate"""
|
||||
|
||||
def __init__(self, source, index_name, counter=0):
|
||||
self.source = source
|
||||
self.index_name = index_name
|
||||
self.counter = counter
|
||||
self.timestamp = datetime.now().strftime("%Y%m%d")
|
||||
self.cache_dir = EnvironmentSettings.CACHE_DIR
|
||||
|
||||
def run(self):
|
||||
"""run the junk task"""
|
||||
file_content = self._build_bulk()
|
||||
self._write_es_json(file_content)
|
||||
|
||||
def _build_bulk(self):
|
||||
"""build bulk query data from all_results"""
|
||||
bulk_list = []
|
||||
|
||||
for document in self.source:
|
||||
document_id = document["_id"]
|
||||
es_index = document["_index"]
|
||||
action = {"index": {"_index": es_index, "_id": document_id}}
|
||||
source = document["_source"]
|
||||
bulk_list.append(json.dumps(action))
|
||||
bulk_list.append(json.dumps(source))
|
||||
|
||||
# add last newline
|
||||
bulk_list.append("\n")
|
||||
file_content = "\n".join(bulk_list)
|
||||
|
||||
return file_content
|
||||
|
||||
def _write_es_json(self, file_content):
|
||||
"""write nd-json file for es _bulk API to disk"""
|
||||
index = self.index_name.lstrip("ta_")
|
||||
file_name = f"es_{index}-{self.timestamp}-{self.counter}.json"
|
||||
file_path = os.path.join(self.cache_dir, "backup", file_name)
|
||||
with open(file_path, "a+", encoding="utf-8") as f:
|
||||
f.write(file_content)
|
||||
230
tubearchivist/home/src/es/connect.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
functionality:
|
||||
- wrapper around requests to call elastic search
|
||||
- reusable search_after to extract total index
|
||||
"""
|
||||
# pylint: disable=missing-timeout
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class ElasticWrap:
|
||||
"""makes all calls to elastic search
|
||||
returns response json and status code tuple
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.url: str = f"{EnvironmentSettings.ES_URL}/{path}"
|
||||
self.auth: tuple[str, str] = (
|
||||
EnvironmentSettings.ES_USER,
|
||||
EnvironmentSettings.ES_PASS,
|
||||
)
|
||||
|
||||
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
def get(
|
||||
self,
|
||||
data: bool | dict = False,
|
||||
timeout: int = 10,
|
||||
print_error: bool = True,
|
||||
) -> tuple[dict, int]:
|
||||
"""get data from es"""
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"auth": self.auth,
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
|
||||
kwargs["verify"] = False
|
||||
|
||||
if data:
|
||||
kwargs["json"] = data
|
||||
|
||||
response = requests.get(self.url, **kwargs)
|
||||
|
||||
if print_error and not response.ok:
|
||||
print(response.text)
|
||||
|
||||
return response.json(), response.status_code
|
||||
|
||||
def post(
|
||||
self, data: bool | dict = False, ndjson: bool = False
|
||||
) -> tuple[dict, int]:
|
||||
"""post data to es"""
|
||||
|
||||
kwargs: dict[str, Any] = {"auth": self.auth}
|
||||
|
||||
if ndjson and data:
|
||||
kwargs.update(
|
||||
{
|
||||
"headers": {"Content-type": "application/x-ndjson"},
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
elif data:
|
||||
kwargs.update(
|
||||
{
|
||||
"headers": {"Content-type": "application/json"},
|
||||
"data": json.dumps(data),
|
||||
}
|
||||
)
|
||||
|
||||
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
|
||||
kwargs["verify"] = False
|
||||
|
||||
response = requests.post(self.url, **kwargs)
|
||||
|
||||
if not response.ok:
|
||||
print(response.text)
|
||||
|
||||
return response.json(), response.status_code
|
||||
|
||||
def put(
|
||||
self,
|
||||
data: bool | dict = False,
|
||||
refresh: bool = False,
|
||||
) -> tuple[dict, Any]:
|
||||
"""put data to es"""
|
||||
|
||||
if refresh:
|
||||
self.url = f"{self.url}/?refresh=true"
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"json": data,
|
||||
"auth": self.auth,
|
||||
}
|
||||
|
||||
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
|
||||
kwargs["verify"] = False
|
||||
|
||||
response = requests.put(self.url, **kwargs)
|
||||
|
||||
if not response.ok:
|
||||
print(response.text)
|
||||
print(data)
|
||||
raise ValueError("failed to add item to index")
|
||||
|
||||
return response.json(), response.status_code
|
||||
|
||||
def delete(
|
||||
self,
|
||||
data: bool | dict = False,
|
||||
refresh: bool = False,
|
||||
) -> tuple[dict, Any]:
|
||||
"""delete document from es"""
|
||||
|
||||
if refresh:
|
||||
self.url = f"{self.url}/?refresh=true"
|
||||
|
||||
kwargs: dict[str, Any] = {"auth": self.auth}
|
||||
|
||||
if data:
|
||||
kwargs["json"] = data
|
||||
|
||||
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
|
||||
kwargs["verify"] = False
|
||||
|
||||
response = requests.delete(self.url, **kwargs)
|
||||
|
||||
if not response.ok:
|
||||
print(response.text)
|
||||
|
||||
return response.json(), response.status_code
|
||||
|
||||
|
||||
class IndexPaginate:
|
||||
"""use search_after to go through whole index
|
||||
kwargs:
|
||||
- size: int, overwrite DEFAULT_SIZE
|
||||
- keep_source: bool, keep _source key from es results
|
||||
- callback: obj, Class implementing run method callback for every loop
|
||||
- task: task object to send notification
|
||||
- total: int, total items in index for progress message
|
||||
"""
|
||||
|
||||
DEFAULT_SIZE = 500
|
||||
|
||||
def __init__(self, index_name, data, **kwargs):
|
||||
self.index_name = index_name
|
||||
self.data = data
|
||||
self.pit_id = False
|
||||
self.kwargs = kwargs
|
||||
|
||||
def get_results(self):
|
||||
"""get all results, add task and total for notifications"""
|
||||
self.get_pit()
|
||||
self.validate_data()
|
||||
all_results = self.run_loop()
|
||||
self.clean_pit()
|
||||
return all_results
|
||||
|
||||
def get_pit(self):
|
||||
"""get pit for index"""
|
||||
path = f"{self.index_name}/_pit?keep_alive=10m"
|
||||
response, _ = ElasticWrap(path).post()
|
||||
self.pit_id = response["id"]
|
||||
|
||||
def validate_data(self):
|
||||
"""add pit and size to data"""
|
||||
if not self.data:
|
||||
self.data = {}
|
||||
|
||||
if "query" not in self.data.keys():
|
||||
self.data.update({"query": {"match_all": {}}})
|
||||
|
||||
if "sort" not in self.data.keys():
|
||||
self.data.update({"sort": [{"_doc": {"order": "desc"}}]})
|
||||
|
||||
self.data["size"] = self.kwargs.get("size") or self.DEFAULT_SIZE
|
||||
self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"}
|
||||
|
||||
def run_loop(self):
|
||||
"""loop through results until last hit"""
|
||||
all_results = []
|
||||
counter = 0
|
||||
while True:
|
||||
response, _ = ElasticWrap("_search").get(data=self.data)
|
||||
all_hits = response["hits"]["hits"]
|
||||
if not all_hits:
|
||||
break
|
||||
|
||||
for hit in all_hits:
|
||||
if self.kwargs.get("keep_source"):
|
||||
all_results.append(hit)
|
||||
else:
|
||||
all_results.append(hit["_source"])
|
||||
|
||||
if self.kwargs.get("callback"):
|
||||
self.kwargs.get("callback")(
|
||||
all_hits, self.index_name, counter=counter
|
||||
).run()
|
||||
|
||||
if self.kwargs.get("task"):
|
||||
print(f"{self.index_name}: processing page {counter}")
|
||||
self._notify(len(all_results))
|
||||
|
||||
counter += 1
|
||||
|
||||
# update search_after with last hit data
|
||||
self.data["search_after"] = all_hits[-1]["sort"]
|
||||
|
||||
return all_results
|
||||
|
||||
def _notify(self, processed):
|
||||
"""send notification on task"""
|
||||
total = self.kwargs.get("total")
|
||||
progress = processed / total
|
||||
index_clean = self.index_name.lstrip("ta_").title()
|
||||
message = [f"Processing {index_clean}s {processed}/{total}"]
|
||||
self.kwargs.get("task").send_progress(message, progress=progress)
|
||||
|
||||
def clean_pit(self):
|
||||
"""delete pit from elastic search"""
|
||||
ElasticWrap("_pit").delete(data={"id": self.pit_id})
|
||||
669
tubearchivist/home/src/es/index_mapping.json
Normal file
@@ -0,0 +1,669 @@
|
||||
{
|
||||
"index_config": [{
|
||||
"index_name": "config",
|
||||
"expected_map": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "channel",
|
||||
"expected_map": {
|
||||
"channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"channel_name": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
},
|
||||
"search_as_you_type": {
|
||||
"type": "search_as_you_type",
|
||||
"doc_values": false,
|
||||
"max_shingle_size": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_banner_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_tvart_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_thumb_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_description": {
|
||||
"type": "text"
|
||||
},
|
||||
"channel_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"channel_tags": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_overwrites": {
|
||||
"properties": {
|
||||
"download_format": {
|
||||
"type": "text"
|
||||
},
|
||||
"autodelete_days": {
|
||||
"type": "long"
|
||||
},
|
||||
"index_playlists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"integrate_sponsorblock": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "video",
|
||||
"expected_map": {
|
||||
"vid_thumb_url": {
|
||||
"type": "text",
|
||||
"index": false
|
||||
},
|
||||
"vid_thumb_base64": {
|
||||
"type": "text",
|
||||
"index": false
|
||||
},
|
||||
"date_downloaded": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"channel": {
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"channel_name": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
},
|
||||
"search_as_you_type": {
|
||||
"type": "search_as_you_type",
|
||||
"doc_values": false,
|
||||
"max_shingle_size": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_banner_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_tvart_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_thumb_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"channel_description": {
|
||||
"type": "text"
|
||||
},
|
||||
"channel_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"channel_tags": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_overwrites": {
|
||||
"properties": {
|
||||
"download_format": {
|
||||
"type": "text"
|
||||
},
|
||||
"autodelete_days": {
|
||||
"type": "long"
|
||||
},
|
||||
"index_playlists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"integrate_sponsorblock": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"media_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"media_size": {
|
||||
"type": "long"
|
||||
},
|
||||
"tags": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
},
|
||||
"search_as_you_type": {
|
||||
"type": "search_as_you_type",
|
||||
"doc_values": false,
|
||||
"max_shingle_size": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"vid_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"youtube_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"vid_type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"published": {
|
||||
"type": "date"
|
||||
},
|
||||
"playlist": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"comment_count": {
|
||||
"type": "long"
|
||||
},
|
||||
"stats": {
|
||||
"properties": {
|
||||
"average_rating": {
|
||||
"type": "float"
|
||||
},
|
||||
"dislike_count": {
|
||||
"type": "long"
|
||||
},
|
||||
"like_count": {
|
||||
"type": "long"
|
||||
},
|
||||
"view_count": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"type": "long"
|
||||
},
|
||||
"duration_str": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"watched": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"watched_date": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"properties": {
|
||||
"ext": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"lang": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"media_url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"name": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"source": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"url": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"streams": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "keyword",
|
||||
"index": false
|
||||
},
|
||||
"index": {
|
||||
"type": "short",
|
||||
"index": false
|
||||
},
|
||||
"codec": {
|
||||
"type": "text"
|
||||
},
|
||||
"width": {
|
||||
"type": "short"
|
||||
},
|
||||
"height": {
|
||||
"type": "short"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sponsorblock": {
|
||||
"properties": {
|
||||
"last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"has_unlocked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"segments": {
|
||||
"properties": {
|
||||
"UUID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"actionType": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"category": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"locked": {
|
||||
"type": "short"
|
||||
},
|
||||
"segment": {
|
||||
"type": "float"
|
||||
},
|
||||
"videoDuration": {
|
||||
"type": "float"
|
||||
},
|
||||
"votes": {
|
||||
"type": "long"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "download",
|
||||
"expected_map": {
|
||||
"timestamp": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"channel_name": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vid_thumb_url": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"youtube_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"vid_type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"auto_start": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "playlist",
|
||||
"expected_map": {
|
||||
"playlist_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"playlist_description": {
|
||||
"type": "text"
|
||||
},
|
||||
"playlist_name": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
},
|
||||
"search_as_you_type": {
|
||||
"type": "search_as_you_type",
|
||||
"doc_values": false,
|
||||
"max_shingle_size": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"playlist_channel": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playlist_channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"playlist_thumbnail": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"playlist_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"playlist_entries": {
|
||||
"properties": {
|
||||
"downloaded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"idx": {
|
||||
"type": "long"
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploader": {
|
||||
"type": "text",
|
||||
"analyzer": "english",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youtube_id": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "subtitle",
|
||||
"expected_map": {
|
||||
"youtube_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle_fragment_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"subtitle_channel": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle_channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"subtitle_start": {
|
||||
"type": "text"
|
||||
},
|
||||
"subtitle_end": {
|
||||
"type": "text"
|
||||
},
|
||||
"subtitle_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"subtitle_index": {
|
||||
"type": "long"
|
||||
},
|
||||
"subtitle_lang": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"subtitle_source": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"subtitle_line": {
|
||||
"type": "text",
|
||||
"analyzer": "english"
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index_name": "comment",
|
||||
"expected_map": {
|
||||
"youtube_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"comment_last_refresh": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"comment_channel_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"comment_comments": {
|
||||
"properties": {
|
||||
"comment_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"comment_text": {
|
||||
"type": "text"
|
||||
},
|
||||
"comment_timestamp": {
|
||||
"type": "date",
|
||||
"format": "epoch_second"
|
||||
},
|
||||
"comment_time_text": {
|
||||
"type": "text"
|
||||
},
|
||||
"comment_likecount": {
|
||||
"type": "long"
|
||||
},
|
||||
"comment_is_favorited": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"comment_author": {
|
||||
"type": "text",
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"type": "keyword",
|
||||
"ignore_above": 256,
|
||||
"normalizer": "to_lower"
|
||||
}
|
||||
}
|
||||
},
|
||||
"comment_author_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"comment_author_thumbnail": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"comment_author_is_uploader": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"comment_parent": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expected_set": {
|
||||
"analysis": {
|
||||
"normalizer": {
|
||||
"to_lower": {
|
||||
"type": "custom",
|
||||
"filter": ["lowercase"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"number_of_replicas": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
210
tubearchivist/home/src/es/index_setup.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
functionality:
|
||||
- setup elastic index at first start
|
||||
- verify and update index mapping and settings if needed
|
||||
- backup and restore metadata
|
||||
"""
|
||||
|
||||
from home.src.es.backup import ElasticBackup
|
||||
from home.src.es.connect import ElasticWrap
|
||||
from home.src.es.snapshot import ElasticSnapshot
|
||||
from home.src.ta.config import AppConfig
|
||||
from home.src.ta.helper import get_mapping
|
||||
|
||||
|
||||
class ElasticIndex:
|
||||
"""interact with a single index"""
|
||||
|
||||
def __init__(self, index_name, expected_map=False, expected_set=False):
|
||||
self.index_name = index_name
|
||||
self.expected_map = expected_map
|
||||
self.expected_set = expected_set
|
||||
self.exists, self.details = self.index_exists()
|
||||
|
||||
def index_exists(self):
|
||||
"""check if index already exists and return mapping if it does"""
|
||||
response, status_code = ElasticWrap(f"ta_{self.index_name}").get()
|
||||
exists = status_code == 200
|
||||
details = response.get(f"ta_{self.index_name}", False)
|
||||
|
||||
return exists, details
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
check if all expected mappings and settings match
|
||||
returns True when rebuild is needed
|
||||
"""
|
||||
|
||||
if self.expected_map:
|
||||
rebuild = self.validate_mappings()
|
||||
if rebuild:
|
||||
return rebuild
|
||||
|
||||
if self.expected_set:
|
||||
rebuild = self.validate_settings()
|
||||
if rebuild:
|
||||
return rebuild
|
||||
|
||||
return False
|
||||
|
||||
def validate_mappings(self):
|
||||
"""check if all mappings are as expected"""
|
||||
now_map = self.details["mappings"]["properties"]
|
||||
|
||||
for key, value in self.expected_map.items():
|
||||
# nested
|
||||
if list(value.keys()) == ["properties"]:
|
||||
for key_n, value_n in value["properties"].items():
|
||||
if key not in now_map:
|
||||
print(f"detected mapping change: {key_n}, {value_n}")
|
||||
return True
|
||||
if key_n not in now_map[key]["properties"].keys():
|
||||
print(f"detected mapping change: {key_n}, {value_n}")
|
||||
return True
|
||||
if not value_n == now_map[key]["properties"][key_n]:
|
||||
print(f"detected mapping change: {key_n}, {value_n}")
|
||||
return True
|
||||
|
||||
continue
|
||||
|
||||
# not nested
|
||||
if key not in now_map.keys():
|
||||
print(f"detected mapping change: {key}, {value}")
|
||||
return True
|
||||
if not value == now_map[key]:
|
||||
print(f"detected mapping change: {key}, {value}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def validate_settings(self):
|
||||
"""check if all settings are as expected"""
|
||||
|
||||
now_set = self.details["settings"]["index"]
|
||||
|
||||
for key, value in self.expected_set.items():
|
||||
if key not in now_set.keys():
|
||||
print(key, value)
|
||||
return True
|
||||
|
||||
if not value == now_set[key]:
|
||||
print(key, value)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def rebuild_index(self):
|
||||
"""rebuild with new mapping"""
|
||||
print(f"applying new mappings to index ta_{self.index_name}...")
|
||||
self.create_blank(for_backup=True)
|
||||
self.reindex("backup")
|
||||
self.delete_index(backup=False)
|
||||
self.create_blank()
|
||||
self.reindex("restore")
|
||||
self.delete_index()
|
||||
|
||||
def reindex(self, method):
|
||||
"""create on elastic search"""
|
||||
if method == "backup":
|
||||
source = f"ta_{self.index_name}"
|
||||
destination = f"ta_{self.index_name}_backup"
|
||||
elif method == "restore":
|
||||
source = f"ta_{self.index_name}_backup"
|
||||
destination = f"ta_{self.index_name}"
|
||||
|
||||
data = {"source": {"index": source}, "dest": {"index": destination}}
|
||||
_, _ = ElasticWrap("_reindex?refresh=true").post(data=data)
|
||||
|
||||
def delete_index(self, backup=True):
|
||||
"""delete index passed as argument"""
|
||||
path = f"ta_{self.index_name}"
|
||||
if backup:
|
||||
path = path + "_backup"
|
||||
|
||||
_, _ = ElasticWrap(path).delete()
|
||||
|
||||
def create_blank(self, for_backup=False):
|
||||
"""apply new mapping and settings for blank new index"""
|
||||
print(f"create new blank index with name ta_{self.index_name}...")
|
||||
path = f"ta_{self.index_name}"
|
||||
if for_backup:
|
||||
path = f"{path}_backup"
|
||||
|
||||
data = {}
|
||||
if self.expected_set:
|
||||
data.update({"settings": self.expected_set})
|
||||
if self.expected_map:
|
||||
data.update({"mappings": {"properties": self.expected_map}})
|
||||
|
||||
_, _ = ElasticWrap(path).put(data)
|
||||
|
||||
|
||||
class ElasitIndexWrap:
|
||||
"""interact with all index mapping and setup"""
|
||||
|
||||
def __init__(self):
|
||||
self.index_config = get_mapping()
|
||||
self.backup_run = False
|
||||
|
||||
def setup(self):
|
||||
"""setup elastic index, run at startup"""
|
||||
for index in self.index_config:
|
||||
index_name, expected_map, expected_set = self._config_split(index)
|
||||
handler = ElasticIndex(index_name, expected_map, expected_set)
|
||||
if not handler.exists:
|
||||
handler.create_blank()
|
||||
continue
|
||||
|
||||
rebuild = handler.validate()
|
||||
if rebuild:
|
||||
self._check_backup()
|
||||
handler.rebuild_index()
|
||||
continue
|
||||
|
||||
# else all good
|
||||
print(f"ta_{index_name} index is created and up to date...")
|
||||
|
||||
def reset(self):
|
||||
"""reset all indexes to blank"""
|
||||
self.delete_all()
|
||||
self.create_all_blank()
|
||||
|
||||
def delete_all(self):
|
||||
"""delete all indexes"""
|
||||
print("reset elastic index")
|
||||
for index in self.index_config:
|
||||
index_name, _, _ = self._config_split(index)
|
||||
handler = ElasticIndex(index_name)
|
||||
handler.delete_index(backup=False)
|
||||
|
||||
def create_all_blank(self):
|
||||
"""create all blank indexes"""
|
||||
print("create all new indexes in elastic from template")
|
||||
for index in self.index_config:
|
||||
index_name, expected_map, expected_set = self._config_split(index)
|
||||
handler = ElasticIndex(index_name, expected_map, expected_set)
|
||||
handler.create_blank()
|
||||
|
||||
@staticmethod
|
||||
def _config_split(index):
|
||||
"""split index config keys"""
|
||||
index_name = index["index_name"]
|
||||
expected_map = index["expected_map"]
|
||||
expected_set = index["expected_set"]
|
||||
|
||||
return index_name, expected_map, expected_set
|
||||
|
||||
def _check_backup(self):
|
||||
"""create backup if needed"""
|
||||
if self.backup_run:
|
||||
return
|
||||
|
||||
config = AppConfig().config
|
||||
if config["application"]["enable_snapshot"]:
|
||||
# take snapshot if enabled
|
||||
ElasticSnapshot().take_snapshot_now(wait=True)
|
||||
else:
|
||||
# fallback to json backup
|
||||
ElasticBackup(reason="update").backup_all_indexes()
|
||||
|
||||
self.backup_run = True
|
||||
286
tubearchivist/home/src/es/snapshot.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
functionality:
|
||||
- handle snapshots in ES
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from home.src.es.connect import ElasticWrap
|
||||
from home.src.ta.helper import get_mapping
|
||||
from home.src.ta.settings import EnvironmentSettings
|
||||
|
||||
|
||||
class ElasticSnapshot:
|
||||
"""interact with snapshots on ES"""
|
||||
|
||||
REPO = "ta_snapshot"
|
||||
REPO_SETTINGS = {
|
||||
"compress": "true",
|
||||
"chunk_size": "1g",
|
||||
"location": EnvironmentSettings.ES_SNAPSHOT_DIR,
|
||||
}
|
||||
POLICY = "ta_daily"
|
||||
|
||||
def __init__(self):
|
||||
self.all_indices = self._get_all_indices()
|
||||
|
||||
def _get_all_indices(self):
|
||||
"""return all indices names managed by TA"""
|
||||
mapping = get_mapping()
|
||||
all_indices = [f"ta_{i['index_name']}" for i in mapping]
|
||||
|
||||
return all_indices
|
||||
|
||||
def setup(self):
|
||||
"""setup the snapshot in ES, create or update if needed"""
|
||||
print("snapshot: run setup")
|
||||
repo_exists = self._check_repo_exists()
|
||||
if not repo_exists:
|
||||
self.create_repo()
|
||||
|
||||
policy_exists = self._check_policy_exists()
|
||||
if not policy_exists:
|
||||
self.create_policy()
|
||||
|
||||
is_outdated = self._needs_startup_snapshot()
|
||||
if is_outdated:
|
||||
_ = self.take_snapshot_now()
|
||||
|
||||
def _check_repo_exists(self):
|
||||
"""check if expected repo already exists"""
|
||||
path = f"_snapshot/{self.REPO}"
|
||||
response, statuscode = ElasticWrap(path).get()
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: repo {self.REPO} already created")
|
||||
matching = response[self.REPO]["settings"] == self.REPO_SETTINGS
|
||||
if not matching:
|
||||
print(f"snapshot: update repo settings {self.REPO_SETTINGS}")
|
||||
|
||||
return matching
|
||||
|
||||
print(f"snapshot: setup repo {self.REPO} config {self.REPO_SETTINGS}")
|
||||
return False
|
||||
|
||||
def create_repo(self):
|
||||
"""create filesystem repo"""
|
||||
path = f"_snapshot/{self.REPO}"
|
||||
data = {
|
||||
"type": "fs",
|
||||
"settings": self.REPO_SETTINGS,
|
||||
}
|
||||
response, statuscode = ElasticWrap(path).post(data=data)
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: repo setup correctly: {response}")
|
||||
|
||||
def _check_policy_exists(self):
|
||||
"""check if snapshot policy is set correctly"""
|
||||
policy = self._get_policy()
|
||||
expected_policy = self._build_policy_data()
|
||||
if not policy:
|
||||
print(f"snapshot: create policy {self.POLICY} {expected_policy}")
|
||||
return False
|
||||
|
||||
if policy["policy"] != expected_policy:
|
||||
print(f"snapshot: update policy settings {expected_policy}")
|
||||
return False
|
||||
|
||||
print("snapshot: policy is set.")
|
||||
return True
|
||||
|
||||
def _get_policy(self):
|
||||
"""get policy from es"""
|
||||
path = f"_slm/policy/{self.POLICY}"
|
||||
response, statuscode = ElasticWrap(path).get()
|
||||
if statuscode != 200:
|
||||
return False
|
||||
|
||||
return response[self.POLICY]
|
||||
|
||||
def create_policy(self):
|
||||
"""create snapshot lifetime policy"""
|
||||
path = f"_slm/policy/{self.POLICY}"
|
||||
data = self._build_policy_data()
|
||||
response, statuscode = ElasticWrap(path).put(data)
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: policy setup correctly: {response}")
|
||||
|
||||
def _build_policy_data(self):
|
||||
"""build policy dict from config"""
|
||||
at_12 = datetime.now().replace(hour=12, minute=0, second=0)
|
||||
hour = at_12.astimezone(ZoneInfo("UTC")).hour
|
||||
|
||||
return {
|
||||
"schedule": f"0 0 {hour} * * ?",
|
||||
"name": f"<{self.POLICY}_>",
|
||||
"repository": self.REPO,
|
||||
"config": {
|
||||
"indices": self.all_indices,
|
||||
"ignore_unavailable": True,
|
||||
"include_global_state": True,
|
||||
},
|
||||
"retention": {
|
||||
"expire_after": "30d",
|
||||
"min_count": 5,
|
||||
"max_count": 50,
|
||||
},
|
||||
}
|
||||
|
||||
def _needs_startup_snapshot(self):
|
||||
"""check if last snapshot is expired"""
|
||||
snap_dicts = self._get_all_snapshots()
|
||||
if not snap_dicts:
|
||||
print("snapshot: create initial snapshot")
|
||||
return True
|
||||
|
||||
last_stamp = snap_dicts[0]["end_stamp"]
|
||||
now = int(datetime.now().timestamp())
|
||||
outdated = (now - last_stamp) / 60 / 60 > 24
|
||||
if outdated:
|
||||
print("snapshot: is outdated, create new now")
|
||||
|
||||
print("snapshot: last snapshot is up-to-date")
|
||||
return outdated
|
||||
|
||||
def take_snapshot_now(self, wait=False):
|
||||
"""execute daily snapshot now"""
|
||||
path = f"_slm/policy/{self.POLICY}/_execute"
|
||||
response, statuscode = ElasticWrap(path).post()
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: executing now: {response}")
|
||||
|
||||
if wait:
|
||||
self._wait_for_snapshot(response["snapshot_name"])
|
||||
|
||||
return response
|
||||
|
||||
def _wait_for_snapshot(self, snapshot_name):
|
||||
"""return after snapshot_name completes"""
|
||||
path = f"_snapshot/{self.REPO}/{snapshot_name}"
|
||||
|
||||
while True:
|
||||
# wait for task to be created
|
||||
sleep(1)
|
||||
_, statuscode = ElasticWrap(path).get()
|
||||
if statuscode == 200:
|
||||
break
|
||||
|
||||
while True:
|
||||
# wait for snapshot success
|
||||
response, statuscode = ElasticWrap(path).get()
|
||||
snapshot_state = response["snapshots"][0]["state"]
|
||||
if snapshot_state == "SUCCESS":
|
||||
break
|
||||
|
||||
print(f"snapshot: {snapshot_name} in state {snapshot_state}")
|
||||
print("snapshot: wait to complete")
|
||||
sleep(5)
|
||||
|
||||
print(f"snapshot: completed - {response}")
|
||||
|
||||
def get_snapshot_stats(self):
|
||||
"""get snapshot info for frontend"""
|
||||
snapshot_info = self._build_policy_details()
|
||||
if snapshot_info:
|
||||
snapshot_info.update({"snapshots": self._get_all_snapshots()})
|
||||
|
||||
return snapshot_info
|
||||
|
||||
def get_single_snapshot(self, snapshot_id):
|
||||
"""get single snapshot metadata"""
|
||||
path = f"_snapshot/{self.REPO}/{snapshot_id}"
|
||||
response, statuscode = ElasticWrap(path).get()
|
||||
if statuscode == 404:
|
||||
print(f"snapshots: not found: {snapshot_id}")
|
||||
return False
|
||||
|
||||
snapshot = response["snapshots"][0]
|
||||
return self._parse_single_snapshot(snapshot)
|
||||
|
||||
def _get_all_snapshots(self):
|
||||
"""get a list of all registered snapshots"""
|
||||
path = f"_snapshot/{self.REPO}/*?sort=start_time&order=desc"
|
||||
response, statuscode = ElasticWrap(path).get()
|
||||
if statuscode == 404:
|
||||
print("snapshots: not configured")
|
||||
return False
|
||||
|
||||
all_snapshots = response["snapshots"]
|
||||
if not all_snapshots:
|
||||
print("snapshots: no snapshots found")
|
||||
return False
|
||||
|
||||
snap_dicts = []
|
||||
for snapshot in all_snapshots:
|
||||
snap_dict = self._parse_single_snapshot(snapshot)
|
||||
snap_dicts.append(snap_dict)
|
||||
|
||||
return snap_dicts
|
||||
|
||||
def _parse_single_snapshot(self, snapshot):
|
||||
"""extract relevant metadata from single snapshot"""
|
||||
snap_dict = {
|
||||
"id": snapshot["snapshot"],
|
||||
"state": snapshot["state"],
|
||||
"es_version": snapshot["version"],
|
||||
"start_date": self._date_converter(snapshot["start_time"]),
|
||||
"end_date": self._date_converter(snapshot["end_time"]),
|
||||
"end_stamp": snapshot["end_time_in_millis"] // 1000,
|
||||
"duration_s": snapshot["duration_in_millis"] // 1000,
|
||||
}
|
||||
return snap_dict
|
||||
|
||||
def _build_policy_details(self):
|
||||
"""get additional policy details"""
|
||||
policy = self._get_policy()
|
||||
if not policy:
|
||||
return False
|
||||
|
||||
next_exec = policy["next_execution_millis"] // 1000
|
||||
next_exec_date = datetime.fromtimestamp(next_exec)
|
||||
next_exec_str = next_exec_date.strftime("%Y-%m-%d %H:%M")
|
||||
expire_after = policy["policy"]["retention"]["expire_after"]
|
||||
policy_metadata = {
|
||||
"next_exec": next_exec,
|
||||
"next_exec_str": next_exec_str,
|
||||
"expire_after": expire_after,
|
||||
}
|
||||
return policy_metadata
|
||||
|
||||
@staticmethod
|
||||
def _date_converter(date_utc):
|
||||
"""convert datetime string"""
|
||||
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
date = datetime.strptime(date_utc, expected_format)
|
||||
local_datetime = date.replace(tzinfo=ZoneInfo("localtime"))
|
||||
converted = local_datetime.astimezone(ZoneInfo(EnvironmentSettings.TZ))
|
||||
converted_str = converted.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
return converted_str
|
||||
|
||||
def restore_all(self, snapshot_name):
|
||||
"""restore snapshot by name"""
|
||||
for index in self.all_indices:
|
||||
_, _ = ElasticWrap(index).delete()
|
||||
|
||||
path = f"_snapshot/{self.REPO}/{snapshot_name}/_restore"
|
||||
data = {"indices": "*"}
|
||||
response, statuscode = ElasticWrap(path).post(data=data)
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: executing now: {response}")
|
||||
return response
|
||||
|
||||
print(f"snapshot: failed to restore, {statuscode} {response}")
|
||||
return False
|
||||
|
||||
def delete_single_snapshot(self, snapshot_id):
|
||||
"""delete single snapshot from index"""
|
||||
path = f"_snapshot/{self.REPO}/{snapshot_id}"
|
||||
response, statuscode = ElasticWrap(path).delete()
|
||||
if statuscode == 200:
|
||||
print(f"snapshot: deleting {snapshot_id} {response}")
|
||||
return response
|
||||
|
||||
print(f"snapshot: failed to delete, {statuscode} {response}")
|
||||
return False
|
||||
0
tubearchivist/home/src/frontend/__init__.py
Normal file
291
tubearchivist/home/src/frontend/forms.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""functionality:
|
||||
- hold all form classes used in the views
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.forms.widgets import PasswordInput, TextInput
|
||||
from home.src.ta.helper import get_stylesheets
|
||||
|
||||
|
||||
class CustomAuthForm(AuthenticationForm):
|
||||
"""better styled login form"""
|
||||
|
||||
username = forms.CharField(
|
||||
widget=TextInput(
|
||||
attrs={
|
||||
"placeholder": "Username",
|
||||
"autofocus": True,
|
||||
"autocomplete": True,
|
||||
}
|
||||
),
|
||||
label=False,
|
||||
)
|
||||
password = forms.CharField(
|
||||
widget=PasswordInput(attrs={"placeholder": "Password"}), label=False
|
||||
)
|
||||
remember_me = forms.BooleanField(required=False)
|
||||
|
||||
|
||||
class UserSettingsForm(forms.Form):
|
||||
"""user configurations values"""
|
||||
|
||||
STYLESHEET_CHOICES = [("", "-- change stylesheet --")]
|
||||
STYLESHEET_CHOICES.extend(
|
||||
[
|
||||
(stylesheet, os.path.splitext(stylesheet)[0].title())
|
||||
for stylesheet in get_stylesheets()
|
||||
]
|
||||
)
|
||||
|
||||
stylesheet = forms.ChoiceField(
|
||||
widget=forms.Select, choices=STYLESHEET_CHOICES, required=False
|
||||
)
|
||||
page_size = forms.IntegerField(required=False)
|
||||
|
||||
|
||||
class ApplicationSettingsForm(forms.Form):
|
||||
"""handle all application settings"""
|
||||
|
||||
AUTOSTART_CHOICES = [
|
||||
("", "-- change subscription autostart --"),
|
||||
("0", "disable auto start"),
|
||||
("1", "enable auto start"),
|
||||
]
|
||||
|
||||
METADATA_CHOICES = [
|
||||
("", "-- change metadata embed --"),
|
||||
("0", "don't embed metadata"),
|
||||
("1", "embed metadata"),
|
||||
]
|
||||
|
||||
THUMBNAIL_CHOICES = [
|
||||
("", "-- change thumbnail embed --"),
|
||||
("0", "don't embed thumbnail"),
|
||||
("1", "embed thumbnail"),
|
||||
]
|
||||
|
||||
RYD_CHOICES = [
|
||||
("", "-- change ryd integrations"),
|
||||
("0", "disable ryd integration"),
|
||||
("1", "enable ryd integration"),
|
||||
]
|
||||
|
||||
SP_CHOICES = [
|
||||
("", "-- change sponsorblock integrations"),
|
||||
("0", "disable sponsorblock integration"),
|
||||
("1", "enable sponsorblock integration"),
|
||||
]
|
||||
|
||||
SNAPSHOT_CHOICES = [
|
||||
("", "-- change snapshot settings --"),
|
||||
("0", "disable system snapshots"),
|
||||
("1", "enable system snapshots"),
|
||||
]
|
||||
|
||||
SUBTITLE_SOURCE_CHOICES = [
|
||||
("", "-- change subtitle source settings"),
|
||||
("user", "only download user created"),
|
||||
("auto", "also download auto generated"),
|
||||
]
|
||||
|
||||
SUBTITLE_INDEX_CHOICES = [
|
||||
("", "-- change subtitle index settings --"),
|
||||
("0", "disable subtitle index"),
|
||||
("1", "enable subtitle index"),
|
||||
]
|
||||
|
||||
COMMENT_SORT_CHOICES = [
|
||||
("", "-- change comments sort settings --"),
|
||||
("top", "sort comments by top"),
|
||||
("new", "sort comments by new"),
|
||||
]
|
||||
|
||||
COOKIE_IMPORT_CHOICES = [
|
||||
("", "-- change cookie settings"),
|
||||
("0", "disable cookie"),
|
||||
("1", "enable cookie"),
|
||||
]
|
||||
|
||||
subscriptions_channel_size = forms.IntegerField(
|
||||
required=False, min_value=1
|
||||
)
|
||||
subscriptions_live_channel_size = forms.IntegerField(
|
||||
required=False, min_value=0
|
||||
)
|
||||
subscriptions_shorts_channel_size = forms.IntegerField(
|
||||
required=False, min_value=0
|
||||
)
|
||||
subscriptions_auto_start = forms.ChoiceField(
|
||||
widget=forms.Select, choices=AUTOSTART_CHOICES, required=False
|
||||
)
|
||||
downloads_limit_speed = forms.IntegerField(required=False)
|
||||
downloads_throttledratelimit = forms.IntegerField(required=False)
|
||||
downloads_sleep_interval = forms.IntegerField(required=False)
|
||||
downloads_autodelete_days = forms.IntegerField(required=False)
|
||||
downloads_format = forms.CharField(required=False)
|
||||
downloads_format_sort = forms.CharField(required=False)
|
||||
downloads_extractor_lang = forms.CharField(required=False)
|
||||
downloads_add_metadata = forms.ChoiceField(
|
||||
widget=forms.Select, choices=METADATA_CHOICES, required=False
|
||||
)
|
||||
downloads_add_thumbnail = forms.ChoiceField(
|
||||
widget=forms.Select, choices=THUMBNAIL_CHOICES, required=False
|
||||
)
|
||||
downloads_subtitle = forms.CharField(required=False)
|
||||
downloads_subtitle_source = forms.ChoiceField(
|
||||
widget=forms.Select, choices=SUBTITLE_SOURCE_CHOICES, required=False
|
||||
)
|
||||
downloads_subtitle_index = forms.ChoiceField(
|
||||
widget=forms.Select, choices=SUBTITLE_INDEX_CHOICES, required=False
|
||||
)
|
||||
downloads_comment_max = forms.CharField(required=False)
|
||||
downloads_comment_sort = forms.ChoiceField(
|
||||
widget=forms.Select, choices=COMMENT_SORT_CHOICES, required=False
|
||||
)
|
||||
downloads_cookie_import = forms.ChoiceField(
|
||||
widget=forms.Select, choices=COOKIE_IMPORT_CHOICES, required=False
|
||||
)
|
||||
downloads_integrate_ryd = forms.ChoiceField(
|
||||
widget=forms.Select, choices=RYD_CHOICES, required=False
|
||||
)
|
||||
downloads_integrate_sponsorblock = forms.ChoiceField(
|
||||
widget=forms.Select, choices=SP_CHOICES, required=False
|
||||
)
|
||||
application_enable_snapshot = forms.ChoiceField(
|
||||
widget=forms.Select, choices=SNAPSHOT_CHOICES, required=False
|
||||
)
|
||||
|
||||
|
||||
class SchedulerSettingsForm(forms.Form):
|
||||
"""handle scheduler settings"""
|
||||
|
||||
HELP_TEXT = "Add Apprise notification URLs, one per line"
|
||||
|
||||
update_subscribed = forms.CharField(required=False)
|
||||
update_subscribed_notify = forms.CharField(
|
||||
label=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 2,
|
||||
"placeholder": HELP_TEXT,
|
||||
}
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
download_pending = forms.CharField(required=False)
|
||||
download_pending_notify = forms.CharField(
|
||||
label=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 2,
|
||||
"placeholder": HELP_TEXT,
|
||||
}
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
check_reindex = forms.CharField(required=False)
|
||||
check_reindex_notify = forms.CharField(
|
||||
label=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 2,
|
||||
"placeholder": HELP_TEXT,
|
||||
}
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
check_reindex_days = forms.IntegerField(required=False)
|
||||
thumbnail_check = forms.CharField(required=False)
|
||||
run_backup = forms.CharField(required=False)
|
||||
run_backup_rotate = forms.IntegerField(required=False)
|
||||
|
||||
|
||||
class MultiSearchForm(forms.Form):
|
||||
"""multi search form for /search/"""
|
||||
|
||||
searchInput = forms.CharField(
|
||||
label="",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"autocomplete": "off",
|
||||
"oninput": "searchMulti(this.value)",
|
||||
"autofocus": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
home = forms.CharField(widget=forms.HiddenInput())
|
||||
channel = forms.CharField(widget=forms.HiddenInput())
|
||||
playlist = forms.CharField(widget=forms.HiddenInput())
|
||||
|
||||
|
||||
class AddToQueueForm(forms.Form):
|
||||
"""text area form to add to downloads"""
|
||||
|
||||
HELP_TEXT = "Enter at least one video, channel or playlist id/URL here..."
|
||||
|
||||
vid_url = forms.CharField(
|
||||
label=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"placeholder": HELP_TEXT,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SubscribeToChannelForm(forms.Form):
|
||||
"""text area form to subscribe to multiple channels"""
|
||||
|
||||
subscribe = forms.CharField(
|
||||
label="Subscribe to channels",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 3,
|
||||
"placeholder": "Input channel ID, URL or Video of a channel",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SubscribeToPlaylistForm(forms.Form):
|
||||
"""text area form to subscribe to multiple playlists"""
|
||||
|
||||
subscribe = forms.CharField(
|
||||
label="Subscribe to playlists",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 3,
|
||||
"placeholder": "Input playlist IDs or URLs",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChannelOverwriteForm(forms.Form):
|
||||
"""custom overwrites for channel settings"""
|
||||
|
||||
PLAYLIST_INDEX = [
|
||||
("", "-- change playlist index --"),
|
||||
("0", "Disable playlist index"),
|
||||
("1", "Enable playlist index"),
|
||||
]
|
||||
|
||||
SP_CHOICES = [
|
||||
("", "-- change sponsorblock integrations"),
|
||||
("disable", "disable sponsorblock integration"),
|
||||
("1", "enable sponsorblock integration"),
|
||||
("0", "unset sponsorblock integration"),
|
||||
]
|
||||
|
||||
download_format = forms.CharField(label=False, required=False)
|
||||
autodelete_days = forms.IntegerField(label=False, required=False)
|
||||
index_playlists = forms.ChoiceField(
|
||||
widget=forms.Select, choices=PLAYLIST_INDEX, required=False
|
||||
)
|
||||
integrate_sponsorblock = forms.ChoiceField(
|
||||
widget=forms.Select, choices=SP_CHOICES, required=False
|
||||
)
|
||||
383
tubearchivist/home/src/frontend/searching.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Functionality:
|
||||
- handle search to populate results to view
|
||||
- cache youtube video thumbnails and channel artwork
|
||||
- parse values in hit_cleanup for frontend
|
||||
- calculate pagination values
|
||||
"""
|
||||
|
||||
|
||||
from api.src.search_processor import SearchProcess
|
||||
from home.src.es.connect import ElasticWrap
|
||||
|
||||
|
||||
class SearchForm:
|
||||
"""build query from search form data"""
|
||||
|
||||
def multi_search(self, search_query):
|
||||
"""searching through index"""
|
||||
path, query, query_type = SearchParser(search_query).run()
|
||||
response, _ = ElasticWrap(path).get(data=query)
|
||||
search_results = SearchProcess(response).process()
|
||||
all_results = self.build_results(search_results)
|
||||
|
||||
return {"results": all_results, "queryType": query_type}
|
||||
|
||||
@staticmethod
|
||||
def build_results(search_results):
|
||||
"""build the all_results dict"""
|
||||
video_results = []
|
||||
channel_results = []
|
||||
playlist_results = []
|
||||
fulltext_results = []
|
||||
if search_results:
|
||||
for result in search_results:
|
||||
if result["_index"] == "ta_video":
|
||||
video_results.append(result)
|
||||
elif result["_index"] == "ta_channel":
|
||||
channel_results.append(result)
|
||||
elif result["_index"] == "ta_playlist":
|
||||
playlist_results.append(result)
|
||||
elif result["_index"] == "ta_subtitle":
|
||||
fulltext_results.append(result)
|
||||
|
||||
all_results = {
|
||||
"video_results": video_results,
|
||||
"channel_results": channel_results,
|
||||
"playlist_results": playlist_results,
|
||||
"fulltext_results": fulltext_results,
|
||||
}
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
class SearchParser:
|
||||
"""handle structured searches"""
|
||||
|
||||
def __init__(self, search_query):
|
||||
self.query_words = search_query.lower().split()
|
||||
self.query_map = {"term": [], "fuzzy": []}
|
||||
self.append_to = "term"
|
||||
|
||||
def run(self):
|
||||
"""collection, return path and query dict for es"""
|
||||
print(f"query words: {self.query_words}")
|
||||
query_type = self._find_map()
|
||||
self._run_words()
|
||||
self._delete_unset()
|
||||
self._match_data_types()
|
||||
|
||||
path, query = QueryBuilder(self.query_map, query_type).run()
|
||||
|
||||
return path, query, query_type
|
||||
|
||||
def _find_map(self):
|
||||
"""find query in keyword map"""
|
||||
first_word = self.query_words[0]
|
||||
key_word_map = self._get_map()
|
||||
|
||||
if ":" in first_word:
|
||||
index_match, query_string = first_word.split(":")
|
||||
if index_match in key_word_map:
|
||||
self.query_map.update(key_word_map.get(index_match))
|
||||
self.query_words[0] = query_string
|
||||
return index_match
|
||||
|
||||
self.query_map.update(key_word_map.get("simple"))
|
||||
print(f"query_map: {self.query_map}")
|
||||
|
||||
return "simple"
|
||||
|
||||
@staticmethod
|
||||
def _get_map():
|
||||
"""return map to build on"""
|
||||
return {
|
||||
"simple": {
|
||||
"index": "ta_video,ta_channel,ta_playlist",
|
||||
},
|
||||
"video": {
|
||||
"index": "ta_video",
|
||||
"channel": [],
|
||||
"active": [],
|
||||
},
|
||||
"channel": {
|
||||
"index": "ta_channel",
|
||||
"active": [],
|
||||
"subscribed": [],
|
||||
},
|
||||
"playlist": {
|
||||
"index": "ta_playlist",
|
||||
"active": [],
|
||||
"subscribed": [],
|
||||
},
|
||||
"full": {
|
||||
"index": "ta_subtitle",
|
||||
"lang": [],
|
||||
"source": [],
|
||||
},
|
||||
}
|
||||
|
||||
def _run_words(self):
|
||||
"""append word by word"""
|
||||
for word in self.query_words:
|
||||
if ":" in word:
|
||||
keyword, search_string = word.split(":")
|
||||
if keyword in self.query_map:
|
||||
self.append_to = keyword
|
||||
word = search_string
|
||||
|
||||
if word:
|
||||
self.query_map[self.append_to].append(word)
|
||||
|
||||
def _delete_unset(self):
|
||||
"""delete unset keys"""
|
||||
new_query_map = {}
|
||||
for key, value in self.query_map.items():
|
||||
if value:
|
||||
new_query_map.update({key: value})
|
||||
self.query_map = new_query_map
|
||||
|
||||
def _match_data_types(self):
|
||||
"""match values with data types"""
|
||||
for key, value in self.query_map.items():
|
||||
if key in ["term", "channel"]:
|
||||
self.query_map[key] = " ".join(self.query_map[key])
|
||||
if key in ["active", "subscribed"]:
|
||||
self.query_map[key] = "yes" in value
|
||||
|
||||
|
||||
class QueryBuilder:
|
||||
"""build query for ES from form data"""
|
||||
|
||||
def __init__(self, query_map, query_type):
|
||||
self.query_map = query_map
|
||||
self.query_type = query_type
|
||||
|
||||
def run(self):
|
||||
"""build query"""
|
||||
path = self._build_path()
|
||||
query = self.build_query()
|
||||
print(f"es path: {path}")
|
||||
print(f"query: {query}")
|
||||
|
||||
return path, query
|
||||
|
||||
def _build_path(self):
|
||||
"""build es index search path"""
|
||||
return f"{self.query_map.get('index')}/_search"
|
||||
|
||||
def build_query(self):
|
||||
"""build query based on query_type"""
|
||||
|
||||
exec_map = {
|
||||
"simple": self._build_simple,
|
||||
"video": self._build_video,
|
||||
"channel": self._build_channel,
|
||||
"playlist": self._build_playlist,
|
||||
"full": self._build_fulltext,
|
||||
}
|
||||
|
||||
build_must_list = exec_map[self.query_type]
|
||||
|
||||
if self.query_type == "full":
|
||||
query = build_must_list()
|
||||
else:
|
||||
query = {
|
||||
"size": 30,
|
||||
"query": {"bool": {"must": build_must_list()}},
|
||||
}
|
||||
|
||||
return query
|
||||
|
||||
def _get_fuzzy(self):
|
||||
"""return fuziness valuee"""
|
||||
fuzzy_value = self.query_map.get("fuzzy", ["auto"])[0]
|
||||
if fuzzy_value == "no":
|
||||
return 0
|
||||
|
||||
if not fuzzy_value.isdigit():
|
||||
return "auto"
|
||||
|
||||
if int(fuzzy_value) > 2:
|
||||
return "2"
|
||||
|
||||
return fuzzy_value
|
||||
|
||||
def _build_simple(self):
|
||||
"""build simple cross index query"""
|
||||
must_list = []
|
||||
|
||||
if (term := self.query_map.get("term")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": term,
|
||||
"type": "bool_prefix",
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
"operator": "and",
|
||||
"fields": [
|
||||
"channel_name._2gram",
|
||||
"channel_name._3gram",
|
||||
"channel_name.search_as_you_type",
|
||||
"playlist_name._2gram",
|
||||
"playlist_name._3gram",
|
||||
"playlist_name.search_as_you_type",
|
||||
"title._2gram",
|
||||
"title._3gram",
|
||||
"title.search_as_you_type",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return must_list
|
||||
|
||||
def _build_video(self):
|
||||
"""build video query"""
|
||||
must_list = []
|
||||
|
||||
if (term := self.query_map.get("term")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": term,
|
||||
"type": "bool_prefix",
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
"operator": "and",
|
||||
"fields": [
|
||||
"title._2gram^2",
|
||||
"title._3gram^2",
|
||||
"title.search_as_you_type^2",
|
||||
"tags",
|
||||
"category",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (active := self.query_map.get("active")) is not None:
|
||||
must_list.append({"term": {"active": {"value": active}}})
|
||||
|
||||
if (channel := self.query_map.get("channel")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": channel,
|
||||
"type": "bool_prefix",
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
"operator": "and",
|
||||
"fields": [
|
||||
"channel.channel_name._2gram",
|
||||
"channel.channel_name._3gram",
|
||||
"channel.channel_name.search_as_you_type",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return must_list
|
||||
|
||||
def _build_channel(self):
|
||||
"""build query for channel"""
|
||||
must_list = []
|
||||
|
||||
if (term := self.query_map.get("term")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": term,
|
||||
"type": "bool_prefix",
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
"operator": "and",
|
||||
"fields": [
|
||||
"channel_description",
|
||||
"channel_name._2gram^2",
|
||||
"channel_name._3gram^2",
|
||||
"channel_name.search_as_you_type^2",
|
||||
"channel_tags",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (active := self.query_map.get("active")) is not None:
|
||||
must_list.append({"term": {"channel_active": {"value": active}}})
|
||||
|
||||
if (subscribed := self.query_map.get("subscribed")) is not None:
|
||||
must_list.append(
|
||||
{"term": {"channel_subscribed": {"value": subscribed}}}
|
||||
)
|
||||
|
||||
return must_list
|
||||
|
||||
def _build_playlist(self):
|
||||
"""build query for playlist"""
|
||||
must_list = []
|
||||
|
||||
if (term := self.query_map.get("term")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": term,
|
||||
"type": "bool_prefix",
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
"operator": "and",
|
||||
"fields": [
|
||||
"playlist_description",
|
||||
"playlist_name._2gram^2",
|
||||
"playlist_name._3gram^2",
|
||||
"playlist_name.search_as_you_type^2",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (active := self.query_map.get("active")) is not None:
|
||||
must_list.append({"term": {"playlist_active": {"value": active}}})
|
||||
|
||||
if (subscribed := self.query_map.get("subscribed")) is not None:
|
||||
must_list.append(
|
||||
{"term": {"playlist_subscribed": {"value": subscribed}}}
|
||||
)
|
||||
|
||||
return must_list
|
||||
|
||||
def _build_fulltext(self):
|
||||
"""build query for fulltext search"""
|
||||
must_list = []
|
||||
|
||||
if (term := self.query_map.get("term")) is not None:
|
||||
must_list.append(
|
||||
{
|
||||
"match": {
|
||||
"subtitle_line": {
|
||||
"query": term,
|
||||
"fuzziness": self._get_fuzzy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (lang := self.query_map.get("lang")) is not None:
|
||||
must_list.append({"term": {"subtitle_lang": {"value": lang[0]}}})
|
||||
|
||||
if (source := self.query_map.get("source")) is not None:
|
||||
must_list.append(
|
||||
{"term": {"subtitle_source": {"value": source[0]}}}
|
||||
)
|
||||
|
||||
query = {
|
||||
"size": 30,
|
||||
"query": {"bool": {"must": must_list}},
|
||||
"highlight": {
|
||||
"fields": {
|
||||
"subtitle_line": {
|
||||
"number_of_fragments": 0,
|
||||
"pre_tags": ['<span class="settings-current">'],
|
||||
"post_tags": ["</span>"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return query
|
||||
105
tubearchivist/home/src/frontend/watched.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
functionality:
|
||||
- handle watched state for videos, channels and playlists
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from home.src.es.connect import ElasticWrap
|
||||
from home.src.ta.urlparser import Parser
|
||||
|
||||
|
||||
class WatchState:
|
||||
"""handle watched checkbox for videos and channels"""
|
||||
|
||||
def __init__(self, youtube_id, is_watched):
|
||||
self.youtube_id = youtube_id
|
||||
self.is_watched = is_watched
|
||||
self.stamp = int(datetime.now().timestamp())
|
||||
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
|
||||
|
||||
def change(self):
|
||||
"""change watched state of item(s)"""
|
||||
print(f"{self.youtube_id}: change watched state to {self.is_watched}")
|
||||
url_type = self._dedect_type()
|
||||
if url_type == "video":
|
||||
self.change_vid_state()
|
||||
return
|
||||
|
||||
self._add_pipeline()
|
||||
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
|
||||
data = self._build_update_data(url_type)
|
||||
_, _ = ElasticWrap(path).post(data)
|
||||
self._delete_pipeline()
|
||||
|
||||
def _dedect_type(self):
|
||||
"""find youtube id type"""
|
||||
url_process = Parser(self.youtube_id).parse()
|
||||
url_type = url_process[0]["type"]
|
||||
return url_type
|
||||
|
||||
def change_vid_state(self):
|
||||
"""change watched state of video"""
|
||||
path = f"ta_video/_update/{self.youtube_id}"
|
||||
data = {
|
||||
"doc": {
|
||||
"player": {
|
||||
"watched": self.is_watched,
|
||||
"watched_date": self.stamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
response, status_code = ElasticWrap(path).post(data=data)
|
||||
if status_code != 200:
|
||||
print(response)
|
||||
raise ValueError("failed to mark video as watched")
|
||||
|
||||
def _build_update_data(self, url_type):
|
||||
"""build update by query data based on url_type"""
|
||||
term_key_map = {
|
||||
"channel": "channel.channel_id",
|
||||
"playlist": "playlist.keyword",
|
||||
}
|
||||
term_key = term_key_map.get(url_type)
|
||||
|
||||
return {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"term": {term_key: {"value": self.youtube_id}}},
|
||||
{
|
||||
"term": {
|
||||
"player.watched": {
|
||||
"value": not self.is_watched
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _add_pipeline(self):
|
||||
"""add ingest pipeline"""
|
||||
data = {
|
||||
"description": f"{self.youtube_id}: watched {self.is_watched}",
|
||||
"processors": [
|
||||
{
|
||||
"set": {
|
||||
"field": "player.watched",
|
||||
"value": self.is_watched,
|
||||
}
|
||||
},
|
||||
{
|
||||
"set": {
|
||||
"field": "player.watched_date",
|
||||
"value": self.stamp,
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
_, _ = ElasticWrap(self.pipeline).put(data)
|
||||
|
||||
def _delete_pipeline(self):
|
||||
"""delete pipeline"""
|
||||
ElasticWrap(self.pipeline).delete()
|
||||