mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-08 21:39:32 +00:00
Compare commits
577 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b53e6820 | ||
|
|
2c2129179a | ||
|
|
ffe9444295 | ||
|
|
f8c5efb87a | ||
|
|
e62a4e0fcf | ||
|
|
b495761e9e | ||
|
|
b7b6ae0216 | ||
|
|
aa08701049 | ||
|
|
436a641746 | ||
|
|
710b0ddc2d | ||
|
|
703fd63f44 | ||
|
|
f040ac6b34 | ||
|
|
aebde2b993 | ||
|
|
372275c4d5 | ||
|
|
e68a9f9f1f | ||
|
|
b239f4bd84 | ||
|
|
8a227fc9b8 | ||
|
|
8c59d65ee5 | ||
|
|
d196d2e4f5 | ||
|
|
62ea518e1b | ||
|
|
a961c8f175 | ||
|
|
ec5204cd6c | ||
|
|
37a6922718 | ||
|
|
a82e4b1c51 | ||
|
|
58b4e22df4 | ||
|
|
efe1401518 | ||
|
|
02b5ed6917 | ||
|
|
fd3ccbec3a | ||
|
|
717d2b3098 | ||
|
|
068cd2e407 | ||
|
|
ef07dc91b4 | ||
|
|
514ad0d16b | ||
|
|
72d81cc45a | ||
|
|
6789cc90d8 | ||
|
|
e7f1921986 | ||
|
|
0ba6169524 | ||
|
|
6e5fac847f | ||
|
|
0a5ce3f786 | ||
|
|
0db4ae3c19 | ||
|
|
56f5c6bc4b | ||
|
|
008dc79ebc | ||
|
|
e34da1687e | ||
|
|
c5f212f520 | ||
|
|
6eb774be69 | ||
|
|
f53988b826 | ||
|
|
70b67e5f77 | ||
|
|
25b6ca2a1a | ||
|
|
776481c513 | ||
|
|
1d727a1170 | ||
|
|
270e6e8320 | ||
|
|
897250da6b | ||
|
|
af33dbb22d | ||
|
|
32cc2c8a13 | ||
|
|
c00368d1f2 | ||
|
|
e28a6be3c5 | ||
|
|
2b71dbca9a | ||
|
|
b7a29897dd | ||
|
|
68a69185fe | ||
|
|
253571b5ac | ||
|
|
3cd6bad9d3 | ||
|
|
5ca295ccff | ||
|
|
8fd217e6f0 | ||
|
|
85be8f1114 | ||
|
|
e254a848e0 | ||
|
|
947ec803b3 | ||
|
|
0d1bdb8904 | ||
|
|
a2cb2d283c | ||
|
|
11afb29384 | ||
|
|
cbfb66b5c8 | ||
|
|
7b26a01063 | ||
|
|
96b55bf9d5 | ||
|
|
50a3442ca6 | ||
|
|
c407f27ecf | ||
|
|
148f9e801b | ||
|
|
5fe0afaeb7 | ||
|
|
c73a350095 | ||
|
|
2be190aa65 | ||
|
|
0aa5ef5b31 | ||
|
|
40bae8d73d | ||
|
|
8f34626a27 | ||
|
|
8978e6510a | ||
|
|
04ea6254b7 | ||
|
|
f18d812eb8 | ||
|
|
d69c579839 | ||
|
|
9fe11c3d22 | ||
|
|
f8846836e7 | ||
|
|
3ab218d744 | ||
|
|
c6329571ae | ||
|
|
4ae59848b4 | ||
|
|
17aa693fdf | ||
|
|
164f3d2a22 | ||
|
|
512f661823 | ||
|
|
7fdb93f183 | ||
|
|
edcede5de6 | ||
|
|
52083e6fb7 | ||
|
|
a4824497ef | ||
|
|
c5f549967b | ||
|
|
87d4f16456 | ||
|
|
f0bac927fe | ||
|
|
4b19294899 | ||
|
|
9f351b5907 | ||
|
|
677f85b993 | ||
|
|
158115d319 | ||
|
|
7abcfcc579 | ||
|
|
862a854e64 | ||
|
|
e06e1be433 | ||
|
|
ac7ce2ce3c | ||
|
|
264394a50b | ||
|
|
b3376318ac | ||
|
|
680279f6a2 | ||
|
|
d4cfb783ef | ||
|
|
edaf1cdd7d | ||
|
|
01f2b42d33 | ||
|
|
45907cad8b | ||
|
|
fd9ce89bb1 | ||
|
|
3e65ebfed1 | ||
|
|
7425732920 | ||
|
|
f0e932544e | ||
|
|
7229cf1930 | ||
|
|
73f7e8945c | ||
|
|
0a801e6b72 | ||
|
|
11a518889a | ||
|
|
9c847f492a | ||
|
|
0fc86407be | ||
|
|
838c6ae145 | ||
|
|
b7e23db750 | ||
|
|
13098b5496 | ||
|
|
139f1ff937 | ||
|
|
d9b5576534 | ||
|
|
b03b09afe8 | ||
|
|
70031be8b7 | ||
|
|
934c92421f | ||
|
|
b4c6b52f32 | ||
|
|
329478c05d | ||
|
|
ab83bd9b16 | ||
|
|
0a0a59b390 | ||
|
|
37c8428c56 | ||
|
|
affd7e6be2 | ||
|
|
9f0621efc8 | ||
|
|
5751f13385 | ||
|
|
cc6ebae295 | ||
|
|
60724df45a | ||
|
|
83cfa03e11 | ||
|
|
bbf2648f89 | ||
|
|
724d5556d4 | ||
|
|
464619cc00 | ||
|
|
eb78a3904f | ||
|
|
837c4293bc | ||
|
|
dfddf8cb4d | ||
|
|
598fa4e674 | ||
|
|
3085fbf348 | ||
|
|
0ea4c87b9e | ||
|
|
cfcb4e6f9e | ||
|
|
711488b471 | ||
|
|
3b78a9868d | ||
|
|
59cc177913 | ||
|
|
c18090e169 | ||
|
|
8a08e7dcca | ||
|
|
a0f31a831f | ||
|
|
476f663478 | ||
|
|
03279979f9 | ||
|
|
b33527adc2 | ||
|
|
718d379c3c | ||
|
|
3a2701f2fa | ||
|
|
b2337669ca | ||
|
|
cc9cb2b578 | ||
|
|
0f61a6272f | ||
|
|
a13cfc9c33 | ||
|
|
e3488ca4d8 | ||
|
|
46c974c078 | ||
|
|
f36490f491 | ||
|
|
f2b1e6962d | ||
|
|
b7eabf02ce | ||
|
|
9a31c2d100 | ||
|
|
d96ac486e4 | ||
|
|
ad7af09159 | ||
|
|
88132fef3c | ||
|
|
5772ab3e68 | ||
|
|
5498958b36 | ||
|
|
b2b25f24ed | ||
|
|
9022817096 | ||
|
|
1035bb0947 | ||
|
|
cec09eda8a | ||
|
|
e6bc16693c | ||
|
|
6723d6152f | ||
|
|
df5a5cf449 | ||
|
|
93406ee0a4 | ||
|
|
e351880d48 | ||
|
|
6275e06db2 | ||
|
|
7e19495c10 | ||
|
|
2083828fe1 | ||
|
|
c71e1acf74 | ||
|
|
4d0dc27ef1 | ||
|
|
1e12a060ce | ||
|
|
8f22d0d9e2 | ||
|
|
3aa41232db | ||
|
|
847764e440 | ||
|
|
2ec81c7ac7 | ||
|
|
c7fc2666fa | ||
|
|
a7d11f53a8 | ||
|
|
a092744958 | ||
|
|
4dafa2f427 | ||
|
|
2f1a43df55 | ||
|
|
3fcef13f14 | ||
|
|
c41872b6b7 | ||
|
|
ccdef58e1d | ||
|
|
5cd67ae315 | ||
|
|
2c8fb72e47 | ||
|
|
45da47f99b | ||
|
|
168412724e | ||
|
|
702000a493 | ||
|
|
b836697e2a | ||
|
|
43bfa1ab93 | ||
|
|
9146ecb82d | ||
|
|
87b65caf8a | ||
|
|
43bd5359ba | ||
|
|
2a331f855c | ||
|
|
937ff5c92c | ||
|
|
d643e8c573 | ||
|
|
4a67da12f7 | ||
|
|
1c643bef8e | ||
|
|
3c06b06960 | ||
|
|
9e4f9de119 | ||
|
|
c2cd02e7bd | ||
|
|
b78d881bf6 | ||
|
|
fe4ee6a2d7 | ||
|
|
baf0c568a8 | ||
|
|
4d23b7dce4 | ||
|
|
a5e97cc4b5 | ||
|
|
0b393304d1 | ||
|
|
074311339a | ||
|
|
206efc784c | ||
|
|
870582f732 | ||
|
|
10a4b13506 | ||
|
|
a49a1d36ff | ||
|
|
40198cd799 | ||
|
|
781dd8d2fc | ||
|
|
78528c4260 | ||
|
|
759f57aa0c | ||
|
|
05ff9df049 | ||
|
|
190d9b3b10 | ||
|
|
5dc25b081c | ||
|
|
726fb6d667 | ||
|
|
73bd392488 | ||
|
|
a68798842b | ||
|
|
cce6d85d69 | ||
|
|
a4978f5ae9 | ||
|
|
641cc361a2 | ||
|
|
26fc8e51d4 | ||
|
|
ffeb7cd95c | ||
|
|
cac61d16d6 | ||
|
|
3dac21c4bc | ||
|
|
f0ab718be0 | ||
|
|
93d28b167c | ||
|
|
a706ba601a | ||
|
|
368a9e73d8 | ||
|
|
d49d549caa | ||
|
|
4bb34b280f | ||
|
|
55aa360c96 | ||
|
|
d408b5bb52 | ||
|
|
c633544eac | ||
|
|
3bbe91416d | ||
|
|
17a00fa361 | ||
|
|
34203b70ae | ||
|
|
a8336ed04d | ||
|
|
bde706cc5e | ||
|
|
0154136114 | ||
|
|
351242a7a8 | ||
|
|
5e5c17a4fb | ||
|
|
0f8b82a1b7 | ||
|
|
0a46284551 | ||
|
|
ff439a996f | ||
|
|
2f8287230f | ||
|
|
58a02df987 | ||
|
|
c8db4b5b37 | ||
|
|
6f5d278732 | ||
|
|
61b53a655a | ||
|
|
c62a495f96 | ||
|
|
868564ee32 | ||
|
|
5b67d9de24 | ||
|
|
1c656f917e | ||
|
|
601f8aff6b | ||
|
|
5668798677 | ||
|
|
08f813a699 | ||
|
|
8526398d49 | ||
|
|
851b499754 | ||
|
|
6549b52112 | ||
|
|
bd58637ec6 | ||
|
|
f85772ca14 | ||
|
|
87cf9fdd53 | ||
|
|
c928ec9b97 | ||
|
|
0b9ef62477 | ||
|
|
bb82661d7e | ||
|
|
cfae159240 | ||
|
|
8fc935d805 | ||
|
|
457f6a0b87 | ||
|
|
2474a6a96b | ||
|
|
73396da7f6 | ||
|
|
21c127d0fa | ||
|
|
56b9c8e921 | ||
|
|
44035187b9 | ||
|
|
d1a42c4b57 | ||
|
|
5cc40315c4 | ||
|
|
bf38cdc7f0 | ||
|
|
f6b5de00df | ||
|
|
1a6f51a122 | ||
|
|
8d4bcb2f4b | ||
|
|
0411b14c5c | ||
|
|
d79fbcd168 | ||
|
|
6b87f2d0fc | ||
|
|
daf09fcc33 | ||
|
|
d33917b541 | ||
|
|
09ef342de7 | ||
|
|
8ca943ca7b | ||
|
|
54e93c44e9 | ||
|
|
d6acef2b64 | ||
|
|
5abe2d0548 | ||
|
|
71797b5050 | ||
|
|
b83e16f3ab | ||
|
|
a7fb5d9939 | ||
|
|
bc74bf80f4 | ||
|
|
cf54f6d7fc | ||
|
|
c9607343e6 | ||
|
|
0eca242fd6 | ||
|
|
bd48b17f9a | ||
|
|
ec18731b61 | ||
|
|
c42c6a0643 | ||
|
|
8da027f320 | ||
|
|
1fa8ab65c9 | ||
|
|
c4b981fb7c | ||
|
|
1177aa5699 | ||
|
|
1f74e02c92 | ||
|
|
2dbce9da4d | ||
|
|
490b61961a | ||
|
|
6e3a647d6a | ||
|
|
be20d56d05 | ||
|
|
a91f2e1b98 | ||
|
|
e8ae261a10 | ||
|
|
25739a1162 | ||
|
|
d0b4f6cdbf | ||
|
|
8e463a818a | ||
|
|
2857f49915 | ||
|
|
fc88ed912c | ||
|
|
86232b8628 | ||
|
|
20e3b7b7ff | ||
|
|
3d43ade857 | ||
|
|
bb67c7e1ab | ||
|
|
815dac618a | ||
|
|
4f8eb4462c | ||
|
|
e87ee7bf72 | ||
|
|
d6235fd272 | ||
|
|
69875c42b9 | ||
|
|
a4435fdd60 | ||
|
|
f25c2025e8 | ||
|
|
1ca34400f0 | ||
|
|
7dcef45b97 | ||
|
|
e5e51f1a60 | ||
|
|
1393d6886e | ||
|
|
c53a706a8e | ||
|
|
29857a9e3f | ||
|
|
058dda7de4 | ||
|
|
f8a0577451 | ||
|
|
fbd7c19a93 | ||
|
|
7ffa6ff807 | ||
|
|
5a5d47da9b | ||
|
|
75339e479e | ||
|
|
e18f176759 | ||
|
|
42f1679502 | ||
|
|
9e796019d2 | ||
|
|
dfe8fe2ac0 | ||
|
|
b1112f5e6d | ||
|
|
1d7833cd24 | ||
|
|
4057e80004 | ||
|
|
7ab81c3fd4 | ||
|
|
9fd2c01966 | ||
|
|
744c6c94f1 | ||
|
|
9288f197ef | ||
|
|
d051f8cb15 | ||
|
|
a3910a7929 | ||
|
|
cc85d8287e | ||
|
|
bfeaefc476 | ||
|
|
376fc071e1 | ||
|
|
2871955b6d | ||
|
|
9b45187db4 | ||
|
|
29ce05b57d | ||
|
|
b269f6ad90 | ||
|
|
c008e1bfba | ||
|
|
de24fcd207 | ||
|
|
df44c02164 | ||
|
|
5a9b66fc90 | ||
|
|
ba42509e80 | ||
|
|
d661e9af25 | ||
|
|
2ed7f5ebc3 | ||
|
|
732c5a7f7d | ||
|
|
b4129cb319 | ||
|
|
1405b7b21f | ||
|
|
7b3e8c1f9d | ||
|
|
aed58c48e0 | ||
|
|
850c2ebf85 | ||
|
|
b4969964fa | ||
|
|
197aa70980 | ||
|
|
39b995fbf1 | ||
|
|
37389e91f5 | ||
|
|
6d27b69799 | ||
|
|
867ed999ee | ||
|
|
3aea299889 | ||
|
|
f05d9bd727 | ||
|
|
cfe94c8dac | ||
|
|
e3e56a31e5 | ||
|
|
83bb7f678b | ||
|
|
4dd7ac496a | ||
|
|
5fc8f4d7a5 | ||
|
|
975fabf8d1 | ||
|
|
9202a90970 | ||
|
|
6aa8b8b05b | ||
|
|
37d6a13cbf | ||
|
|
2ee037c745 | ||
|
|
f981186a93 | ||
|
|
271a595058 | ||
|
|
b454391d6e | ||
|
|
b864813a7b | ||
|
|
192d518c2b | ||
|
|
bf523ccb16 | ||
|
|
2ea4872bc7 | ||
|
|
a5b492fecd | ||
|
|
1d07386a06 | ||
|
|
852dfba8c5 | ||
|
|
812fc633d3 | ||
|
|
9b57b534ea | ||
|
|
fc99a2b532 | ||
|
|
38d74657ec | ||
|
|
49cfbd5796 | ||
|
|
032a28e330 | ||
|
|
224703dcd7 | ||
|
|
44cfb15e0c | ||
|
|
5afbc2e0a5 | ||
|
|
f99e5c0081 | ||
|
|
49f22bc43c | ||
|
|
af18666f09 | ||
|
|
864fe325de | ||
|
|
6ae4f2d6db | ||
|
|
8550421a9a | ||
|
|
aabc883d84 | ||
|
|
90441ba678 | ||
|
|
308d5a0a61 | ||
|
|
3d12fe7b5e | ||
|
|
37df4f8b5b | ||
|
|
4661cd025f | ||
|
|
d6fc78d3b4 | ||
|
|
7d4eecf603 | ||
|
|
899bdc950b | ||
|
|
f368322fea | ||
|
|
f869d0e46a | ||
|
|
1db738ce89 | ||
|
|
adcd4f9245 | ||
|
|
52ac5285b9 | ||
|
|
d621008d31 | ||
|
|
f60411c7a4 | ||
|
|
7af9ba364b | ||
|
|
528bc5f2ef | ||
|
|
4c5f56e191 | ||
|
|
c37242ff4b | ||
|
|
c687c0fd96 | ||
|
|
335e6f4b6f | ||
|
|
b97cb9d264 | ||
|
|
12f9517838 | ||
|
|
eba40af1ab | ||
|
|
807c30caa0 | ||
|
|
24b57406ab | ||
|
|
b0b72a6e6e | ||
|
|
93b921582d | ||
|
|
437cd72f45 | ||
|
|
81bf614afa | ||
|
|
9b84169fab | ||
|
|
381f97e6d2 | ||
|
|
039a49387c | ||
|
|
c9007431bf | ||
|
|
1fcea860c8 | ||
|
|
963d952dfa | ||
|
|
cba6b0a33e | ||
|
|
5fac26156b | ||
|
|
9eba3e278d | ||
|
|
e3d5a0885b | ||
|
|
9f484eca83 | ||
|
|
e8691a8edb | ||
|
|
08346d6dc7 | ||
|
|
64016389f7 | ||
|
|
a3afa23bc7 | ||
|
|
9068e4569d | ||
|
|
97b8439856 | ||
|
|
d4165327df | ||
|
|
9fe0aa85d0 | ||
|
|
87e3814751 | ||
|
|
a201d43bfd | ||
|
|
200f5ed5a2 | ||
|
|
7a265231bc | ||
|
|
ed3183b42c | ||
|
|
a1e3512bab | ||
|
|
241e76c17d | ||
|
|
7111fedd5c | ||
|
|
40beb35f34 | ||
|
|
d7818871a4 | ||
|
|
d3103ccc30 | ||
|
|
30c20b41d9 | ||
|
|
cfa0cabd41 | ||
|
|
8a2040aa26 | ||
|
|
e2253fc63b | ||
|
|
f9eff28da7 | ||
|
|
873e7d1e8d | ||
|
|
91f9351502 | ||
|
|
4a49d70b17 | ||
|
|
ca1864843a | ||
|
|
819d166296 | ||
|
|
d6b5521c11 | ||
|
|
c9285ce3a1 | ||
|
|
3e3e3ae78e | ||
|
|
ae89f47072 | ||
|
|
1bcb4f1f7c | ||
|
|
758038f93d | ||
|
|
d6f679784e | ||
|
|
c7ebbaa207 | ||
|
|
a2000b253b | ||
|
|
6e9ae583c9 | ||
|
|
ee5d73917c | ||
|
|
478d05997c | ||
|
|
0e305f60f4 | ||
|
|
3c441c3a31 | ||
|
|
228677ddfb | ||
|
|
dc0c82c814 | ||
|
|
34ad0ca07d | ||
|
|
c82d9e2140 | ||
|
|
bbf59eaca9 | ||
|
|
e42737ad9b | ||
|
|
1d9c274390 | ||
|
|
2a35b42d88 | ||
|
|
11ab314649 | ||
|
|
d58d133baf | ||
|
|
d6f3fd883c | ||
|
|
8f1a5c8557 | ||
|
|
fdc427977e | ||
|
|
db080e97bb | ||
|
|
5235af3d91 | ||
|
|
6ab70c7602 | ||
|
|
86d157699a | ||
|
|
c176405b32 | ||
|
|
6e7cb74366 | ||
|
|
05cfeb9d99 | ||
|
|
4edb5adead | ||
|
|
c17627a911 | ||
|
|
36a738d5d7 | ||
|
|
d6c4a6ea46 | ||
|
|
77ee9cfc13 | ||
|
|
c413811e17 | ||
|
|
2a9769d154 | ||
|
|
d9ce9641e2 | ||
|
|
f874d402b1 | ||
|
|
97b6d7d606 | ||
|
|
4a38636ef3 | ||
|
|
97bc03f855 | ||
|
|
770990c568 | ||
|
|
ddc4685811 | ||
|
|
56220a94e0 | ||
|
|
fd039de53d | ||
|
|
320ead0bd2 | ||
|
|
e33341d30d | ||
|
|
21b79e7c8f | ||
|
|
9366b8eab9 | ||
|
|
011073617d | ||
|
|
784f90b16d | ||
|
|
c1cd9bc8eb | ||
|
|
e0f1828d9c | ||
|
|
f5a2e624d8 | ||
|
|
dc08c83da5 | ||
|
|
33ecd73137 | ||
|
|
cb6476fa8c | ||
|
|
ec64a88d1e | ||
|
|
852abf254d |
@@ -5,6 +5,13 @@
|
|||||||
# Docker
|
# Docker
|
||||||
.docker
|
.docker
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
backend/static
|
||||||
|
backend/staticfiles
|
||||||
|
|
||||||
|
# Frontend development
|
||||||
|
frontend/node_modules
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
tubearchivist/__pycache__/
|
tubearchivist/__pycache__/
|
||||||
tubearchivist/*/__pycache__/
|
tubearchivist/*/__pycache__/
|
||||||
@@ -18,4 +25,4 @@ venv/
|
|||||||
assets/*
|
assets/*
|
||||||
|
|
||||||
# for local testing only
|
# for local testing only
|
||||||
testing.sh
|
testing.sh
|
||||||
|
|||||||
17
.eslintrc.js
17
.eslintrc.js
@@ -1,17 +0,0 @@
|
|||||||
'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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docker_assets\run.sh eol=lf
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1,3 @@
|
|||||||
github: bbilly1
|
github: bbilly1
|
||||||
ko_fi: bbilly1
|
ko_fi: bbilly1
|
||||||
custom: https://paypal.me/bbilly1
|
custom: https://paypal.me/bbilly1
|
||||||
|
|||||||
30
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
30
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
@@ -1,34 +1,12 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: I have an idea for a great addition to this project
|
description: This Project currently doesn't take any new feature requests.
|
||||||
title: "[Feature Request]: "
|
title: "[Feature Request]: "
|
||||||
|
|
||||||
body:
|
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
|
- type: checkboxes
|
||||||
id: already
|
id: block
|
||||||
attributes:
|
attributes:
|
||||||
label: "I've read the documentation"
|
label: "This project doesn't accept any new feature requests for the foreseeable future. There is no shortage of ideas and the next development steps are clear for years to come."
|
||||||
options:
|
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.
|
- label: I understand that this issue will be closed without comment.
|
||||||
required: true
|
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.
|
|
||||||
|
|||||||
23
.github/ISSUE_TEMPLATE/FRONTEND-MIGRATION.yml
vendored
Normal file
23
.github/ISSUE_TEMPLATE/FRONTEND-MIGRATION.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Frontend Migration
|
||||||
|
description: Tracking our new React based frontend
|
||||||
|
title: "[Frontend Migration]: "
|
||||||
|
labels: ["react migration"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: domain
|
||||||
|
attributes:
|
||||||
|
label: Domain
|
||||||
|
options:
|
||||||
|
- Frontend
|
||||||
|
- Backend
|
||||||
|
- Combined
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
placeholder: Organizing our React frontend migration
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
16
.github/workflows/lint_js.yml
vendored
16
.github/workflows/lint_js.yml
vendored
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
14
.github/workflows/lint_python.yml
vendored
14
.github/workflows/lint_python.yml
vendored
@@ -1,14 +0,0 @@
|
|||||||
name: lint_python
|
|
||||||
on: [pull_request, push]
|
|
||||||
jobs:
|
|
||||||
lint_python:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- 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 requests
|
|
||||||
- run: ./deploy.sh validate
|
|
||||||
47
.github/workflows/pre_commit.yml
vendored
Normal file
47
.github/workflows/pre_commit.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Lint, Test, Build, and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '23'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Cache pre-commit environment
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pre-commit
|
||||||
|
key: ${{ runner.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pre-commit-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
|
- name: Run pre-commit
|
||||||
|
run: |
|
||||||
|
pre-commit run --all-files
|
||||||
43
.github/workflows/unit_tests.yml
vendored
Normal file
43
.github/workflows/unit_tests.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: python_unit_tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '**/*.py'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**/*.py'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc libldap2-dev libsasl2-dev libssl-dev
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Cache pip
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pip-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: pytest backend
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,8 +2,10 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
# django testing db
|
# django testing
|
||||||
db.sqlite3
|
backend/static
|
||||||
|
backend/staticfiles
|
||||||
|
backend/.env
|
||||||
|
|
||||||
# vscode custom conf
|
# vscode custom conf
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
49
.pre-commit-config.yaml
Normal file
49
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 25.1.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
alias: python
|
||||||
|
files: ^backend/
|
||||||
|
args: ["--line-length=79"]
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 6.0.1
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
name: isort (python)
|
||||||
|
alias: python
|
||||||
|
files: ^backend/
|
||||||
|
args: ["--profile", "black", "-l 79"]
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 7.1.2
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
alias: python
|
||||||
|
files: ^backend/
|
||||||
|
args: ["--max-complexity=10", "--max-line-length=79"]
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.4.1
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
exclude: ^frontend/package-lock.json
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
|
rev: v9.22.0
|
||||||
|
hooks:
|
||||||
|
- id: eslint
|
||||||
|
name: eslint
|
||||||
|
files: \.[jt]sx?$
|
||||||
|
types: [file]
|
||||||
|
entry: npm run --prefix ./frontend lint
|
||||||
|
pass_filenames: false
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
rev: v4.0.0-alpha.8
|
||||||
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
entry: npm run --prefix ./frontend format
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
exclude: '.*(\.svg|/migrations/).*'
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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.
|
|
||||||
141
CONTRIBUTING.md
141
CONTRIBUTING.md
@@ -1,8 +1,9 @@
|
|||||||
## Contributing to Tube Archivist
|
# Contributing to Tube Archivist
|
||||||
|
|
||||||
Welcome, and thanks for showing interest in improving Tube Archivist!
|
Welcome, and thanks for showing interest in improving Tube Archivist!
|
||||||
|
|
||||||
## Table of Content
|
## Table of Content
|
||||||
|
- [Beta Testing](#beta-testing)
|
||||||
- [How to open an issue](#how-to-open-an-issue)
|
- [How to open an issue](#how-to-open-an-issue)
|
||||||
- [Bug Report](#bug-report)
|
- [Bug Report](#bug-report)
|
||||||
- [Feature Request](#feature-request)
|
- [Feature Request](#feature-request)
|
||||||
@@ -14,6 +15,19 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
|
|||||||
- [Development Environment](#development-environment)
|
- [Development Environment](#development-environment)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Beta Testing
|
||||||
|
Be the first to help test new features/improvements and provide feedback! Regular `:unstable` builds are available for early access. These are for the tinkerers and the brave. Ideally, use a testing environment first, before upgrading your main installation.
|
||||||
|
|
||||||
|
There is always something that can get missed during development. Look at the commit messages tagged with `#build` - these are the unstable builds and give a quick overview of what has changed.
|
||||||
|
|
||||||
|
- Test the features mentioned, play around, try to break it.
|
||||||
|
- Test the update path by installing the `:latest` release first, then upgrade to `:unstable` to check for any errors.
|
||||||
|
- Test the unstable build on a fresh install.
|
||||||
|
|
||||||
|
Then provide feedback - even if you don't encounter any issues! You can do this in the `#beta-testing` channel on the [Discord](https://tubearchivist.com/discord) Discord server.
|
||||||
|
|
||||||
|
This helps ensure a smooth update for the stable release. Plus you get to test things out early!
|
||||||
|
|
||||||
## How to open an issue
|
## How to open an issue
|
||||||
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
|
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
|
||||||
|
|
||||||
@@ -37,22 +51,7 @@ Please keep in mind:
|
|||||||
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
||||||
|
|
||||||
### Feature Request
|
### 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 interested in helping with overall improvements of the application. Focus is *not* on adding new features, but improving existing ones.
|
This project doesn't take any new feature requests. This project doesn't lack ideas, see the currently open tasks and roadmap. New feature requests aren't helpful at this point in time. Thank you for your understanding.
|
||||||
|
|
||||||
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. 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 small scoped 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
|
### Installation Help
|
||||||
GitHub is most likely not the best place to ask for installation help. That's inherently individual and one on one.
|
GitHub is most likely not the best place to ask for installation help. That's inherently individual and one on one.
|
||||||
@@ -71,23 +70,28 @@ Thank you for contributing and helping improve this project. Focus for the fores
|
|||||||
This is a quick checklist to help streamline the process:
|
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 **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.
|
- 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.
|
- Make sure all your code is linted and formatted correctly, see below.
|
||||||
|
|
||||||
### Making changes to the JavaScript
|
### Documentation Changes
|
||||||
|
|
||||||
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.
|
All documentation is intended to represent the state of the [latest](https://github.com/tubearchivist/tubearchivist/releases/latest) release.
|
||||||
|
|
||||||
|
- If your PR with code changes also requires changes to documentation *.md files here in this repo, create a separate PR for that, so it can be merged separately at release.
|
||||||
|
- You can make the PR directly against the *master* branch.
|
||||||
|
- If your PR requires changes on the [tubearchivist/docs](https://github.com/tubearchivist/docs), make the PR over there.
|
||||||
|
- Prepare your documentation updates at the same time as the code changes, so people testing your PR can consult the prepared docs if needed.
|
||||||
|
|
||||||
### Code formatting and linting
|
### 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
|
This project uses the excellent [pre-commit](https://github.com/pre-commit/pre-commit) library. The [pre-commit-config.yml](https://github.com/tubearchivist/tubearchivist/blob/master/.pre-commit-config.yaml) file is part of this repo.
|
||||||
|
|
||||||
```shell
|
**Quick Start**
|
||||||
./deploy.sh validate tubearchivist/home/src/index/video.py
|
- Run `pre-commit install` from the root of the repo.
|
||||||
```
|
- Next time you commit to your local git repo, the defined hooks will run.
|
||||||
|
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
|
||||||
|
|
||||||
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.
|
That is also running as a Git Hub action.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ to validate your changes. If you omit the path, all the project files will get c
|
|||||||
|
|
||||||
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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -120,15 +124,64 @@ Some of you might have created useful scripts or API integrations around this pr
|
|||||||
|
|
||||||
## Improve to the Documentation
|
## 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.
|
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 there has additional instructions on how to make changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Environment
|
## 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. You might be able to run the application directly, but this document assumes you're using docker.
|
This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers.
|
||||||
|
|
||||||
### Instructions
|
Note:
|
||||||
|
- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers.
|
||||||
|
|
||||||
|
### Native Instruction
|
||||||
|
|
||||||
|
For convenience, it's recommended to still run Redis and ES in a docker container. Make sure both containers can be reachable over the network.
|
||||||
|
|
||||||
|
Set up your virtual environment and install the requirements defined in `requirements-dev.txt`.
|
||||||
|
|
||||||
|
There are options built in to load environment variables from a file using `load_dotenv`. Example `.env` file to place in the same folder as `manage.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
TA_HOST="localhost"
|
||||||
|
TA_USERNAME=tubearchivist
|
||||||
|
TA_PASSWORD=verysecret
|
||||||
|
TA_MEDIA_DIR="static/volume/media"
|
||||||
|
TA_CACHE_DIR="static"
|
||||||
|
TA_APP_DIR="."
|
||||||
|
REDIS_CON=redis://localhost:6379
|
||||||
|
ES_URL="http://localhost:9200"
|
||||||
|
ELASTIC_PASSWORD=verysecret
|
||||||
|
TZ=America/New_York
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
```
|
||||||
|
|
||||||
|
Then look at the container startup script `run.sh`, make sure all needed migrations and startup checks ran. To start the dev backend server from the same folder as `manage.py` run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will be available on [localhost:8000/api/](localhost:8000/api/).
|
||||||
|
|
||||||
|
You'll probably also want to have a Celery worker instance running, refer to `run.sh` for that. The Beat Scheduler might not be needed.
|
||||||
|
|
||||||
|
Then from the frontend folder, install the dependencies with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then to start the frontend development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
And the frontend should be available at [localhost:3000](localhost:3000).
|
||||||
|
|
||||||
|
### Docker Instructions
|
||||||
|
|
||||||
Set up docker on your development machine.
|
Set up docker on your development machine.
|
||||||
|
|
||||||
@@ -144,11 +197,11 @@ Make your changes locally and re-run `docker compose up --build`. The `Dockerfil
|
|||||||
|
|
||||||
### Develop environment inside a VM
|
### 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:
|
You may find it nice to run everything inside of a VM for complete environment snapshots and encapsulation, though this is not strictly necessary. There's a `deploy.sh` script which has some helpers for this use case:
|
||||||
|
|
||||||
- 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.
|
- This assumes a standard Ubuntu Server VM with docker and docker compose already installed.
|
||||||
- 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.
|
- Configure your local DNS to resolve `tubearchivist.local` to the IP of the VM.
|
||||||
- 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:
|
- To deploy the latest changes and rebuild the application to the testing VM run:
|
||||||
```bash
|
```bash
|
||||||
./deploy.sh test
|
./deploy.sh test
|
||||||
```
|
```
|
||||||
@@ -159,7 +212,7 @@ You may find it nice to run everything inside of a VM, though this is not necess
|
|||||||
### Working with Elasticsearch
|
### 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.
|
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.
|
||||||
|
|
||||||
**Quick start**
|
**Quick start**
|
||||||
Generate your access token in Elasitcsearch:
|
Generate your access token in Elasitcsearch:
|
||||||
```bash
|
```bash
|
||||||
bin/elasticsearch-service-tokens create elastic/kibana kibana
|
bin/elasticsearch-service-tokens create elastic/kibana kibana
|
||||||
@@ -179,3 +232,19 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
**Persist Token**
|
||||||
|
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
|
||||||
|
|
||||||
|
1. Create the token as described above
|
||||||
|
2. While the container is running, copy the current config folder out of the container, e.g.:
|
||||||
|
```
|
||||||
|
docker cp archivist-es:/usr/share/elasticsearch/config/ volume/es_config
|
||||||
|
```
|
||||||
|
3. Then stop all containers and mount this folder into the container as an additional volume:
|
||||||
|
```yml
|
||||||
|
- ./volume/es_config:/usr/share/elasticsearch/config
|
||||||
|
```
|
||||||
|
4. Start all containers back up.
|
||||||
|
|
||||||
|
Now your token will persist between ES container rebuilds.
|
||||||
|
|||||||
67
Dockerfile
67
Dockerfile
@@ -1,60 +1,62 @@
|
|||||||
# multi stage to build tube archivist
|
# multi stage to build tube archivist
|
||||||
# first stage to build python wheel, copy into final image
|
# build python wheel, download and extract ffmpeg, copy into final image
|
||||||
|
|
||||||
|
FROM node:lts-alpine AS node-builder
|
||||||
|
|
||||||
|
# RUN npm config set registry https://registry.npmjs.org/
|
||||||
|
|
||||||
|
COPY ./frontend /frontend
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
RUN npm i
|
||||||
|
RUN npm run build:deploy
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
# First stage to build python wheel
|
# First stage to build python wheel
|
||||||
FROM python:3.11.3-slim-bullseye AS builder
|
FROM python:3.11.8-slim-bookworm AS builder
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
|
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
|
||||||
|
|
||||||
# install requirements
|
# install requirements
|
||||||
COPY ./tubearchivist/requirements.txt /requirements.txt
|
COPY ./backend/requirements.txt /requirements.txt
|
||||||
RUN pip install --user -r requirements.txt
|
RUN pip install --user -r requirements.txt
|
||||||
|
|
||||||
# build final image
|
# build ffmpeg
|
||||||
FROM python:3.11.3-slim-bullseye as tubearchivist
|
FROM python:3.11.8-slim-bookworm AS ffmpeg-builder
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
|
||||||
|
RUN python ffmpeg_download.py $TARGETPLATFORM
|
||||||
|
|
||||||
|
# build final image
|
||||||
|
FROM python:3.11.8-slim-bookworm AS tubearchivist
|
||||||
|
|
||||||
ARG INSTALL_DEBUG
|
ARG INSTALL_DEBUG
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# copy build requirements
|
# copy build requirements
|
||||||
COPY --from=builder /root/.local /root/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
ENV PATH=/root/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
|
# copy ffmpeg
|
||||||
|
COPY --from=ffmpeg-builder ./ffmpeg/ffmpeg /usr/bin/ffmpeg
|
||||||
|
COPY --from=ffmpeg-builder ./ffprobe/ffprobe /usr/bin/ffprobe
|
||||||
|
|
||||||
# install distro packages needed
|
# install distro packages needed
|
||||||
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
|
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
|
||||||
nginx \
|
nginx \
|
||||||
atomicparsley \
|
atomicparsley \
|
||||||
curl \
|
curl && rm -rf /var/lib/apt/lists/*
|
||||||
xz-utils && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# 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 ".*master.*linuxarm64.*tar.xz" \
|
|
||||||
| cut -d '"' -f 4 \
|
|
||||||
| 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
|
# install debug tools for testing environment
|
||||||
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
||||||
apt-get -y update && apt-get -y install --no-install-recommends \
|
apt-get -y update && apt-get -y install --no-install-recommends \
|
||||||
vim htop bmon net-tools iputils-ping procps \
|
vim htop bmon net-tools iputils-ping procps lsof \
|
||||||
&& pip install --user ipython \
|
&& pip install --user ipython pytest pytest-django \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
# make folders
|
# make folders
|
||||||
@@ -65,9 +67,12 @@ COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
|
|||||||
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
|
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# copy application into container
|
# copy application into container
|
||||||
COPY ./tubearchivist /app
|
COPY ./backend /app
|
||||||
COPY ./docker_assets/run.sh /app
|
COPY ./docker_assets/run.sh /app
|
||||||
COPY ./docker_assets/uwsgi.ini /app
|
COPY ./docker_assets/backend_start.py /app
|
||||||
|
COPY ./docker_assets/beat_auto_spawn.sh /app
|
||||||
|
|
||||||
|
COPY --from=node-builder ./frontend/dist /app/static
|
||||||
|
|
||||||
# volumes
|
# volumes
|
||||||
VOLUME /cache
|
VOLUME /cache
|
||||||
|
|||||||
118
README.md
118
README.md
@@ -1,10 +1,10 @@
|
|||||||

|

|
||||||
[*more screenshots and video*](SHOWCASE.MD)
|
[*more screenshots and video*](SHOWCASE.MD)
|
||||||
|
|
||||||
<div align="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://hub.docker.com/r/bbilly1/tubearchivist" 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/tubearchivist/tubearchivist/stargazers" 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://github.com/tubearchivist/tubearchivist/forks" 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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -23,41 +23,43 @@
|
|||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
## Core functionality
|
## 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:
|
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
|
* Subscribe to your favorite YouTube channels
|
||||||
* Download Videos using **yt-dlp**
|
* Download Videos using **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**
|
||||||
* Index and make videos searchable
|
* Index and make videos searchable
|
||||||
* Play videos
|
* Play videos
|
||||||
* Keep track of viewed and unviewed videos
|
* Keep track of viewed and unviewed videos
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
||||||
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
|
- [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)
|
- [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.
|
- [Jellyfin Plugin](https://github.com/tubearchivist/tubearchivist-jf-plugin): Add your videos to Jellyfin
|
||||||
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
|
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
|
||||||
|
|
||||||
## Installing
|
## 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.
|
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.
|
This project requires docker. Ensure it is installed and running on your system.
|
||||||
|
|
||||||
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/).
|
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
|
||||||
|
|
||||||
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/).
|
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/).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**TubeArchivist**:
|
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
|
||||||
|
|
||||||
|
**TubeArchivist**:
|
||||||
| Environment Var | Value | |
|
| Environment Var | Value | |
|
||||||
| ----------- | ----------- | ----------- |
|
| ----------- | ----------- | ----------- |
|
||||||
| TA_HOST | Server IP or hostname | Required |
|
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
|
||||||
| TA_USERNAME | Initial username when logging into TA | Required |
|
| TA_USERNAME | Initial username when logging into TA | Required |
|
||||||
| TA_PASSWORD | Initial password when logging into TA | Required |
|
| TA_PASSWORD | Initial password when logging into TA | Required |
|
||||||
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
|
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
|
||||||
| REDIS_HOST | Hostname for Redis | Required |
|
| REDIS_CON | Connection string to Redis | Required |
|
||||||
| TZ | Set your timezone for the scheduler | Required |
|
| TZ | Set your timezone for the scheduler | Required |
|
||||||
| TA_PORT | Overwrite Nginx port | Optional |
|
| TA_PORT | Overwrite Nginx port | Optional |
|
||||||
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional |
|
| TA_BACKEND_PORT | Overwrite container internal backend server port | Optional |
|
||||||
| TA_ENABLE_AUTH_PROXY | Enables support for forwarding auth in reverse proxies | [Read more](https://docs.tubearchivist.com/configuration/forward-auth/) |
|
| 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_USERNAME_HEADER | Header containing username to log in | Optional |
|
||||||
| TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional |
|
| TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional |
|
||||||
@@ -67,12 +69,20 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
|
|||||||
| HOST_GID | Allow TA to own the video files instead of container user | 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 |
|
| HOST_UID | Allow TA to own the video files instead of container user | Optional |
|
||||||
| ELASTIC_USER | Change the default ElasticSearch 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/) |
|
| 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/) |
|
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
|
||||||
| DJANGO_DEBUG | Return additional error messages, for debug only | |
|
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
|
||||||
|
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
|
||||||
|
|
||||||
**ElasticSearch**
|
| TA_LOGIN_AUTH_MODE value | Description |
|
||||||
|
| ------------------------ | ----------- |
|
||||||
|
| single | Only use a single backend (default, or LDAP, or Forward auth, selected by TA_LDAP or TA_ENABLE_AUTH_PROXY) |
|
||||||
|
| local | Use local password database only |
|
||||||
|
| ldap | Use LDAP backend only |
|
||||||
|
| forwardauth | Use reverse proxy headers only |
|
||||||
|
| ldap_local | Use LDAP backend in addition to the local password database |
|
||||||
|
|
||||||
|
**ElasticSearch**
|
||||||
| Environment Var | Value | State |
|
| Environment Var | Value | State |
|
||||||
| ----------- | ----------- | ----------- |
|
| ----------- | ----------- | ----------- |
|
||||||
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
||||||
@@ -80,58 +90,58 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
|
|||||||
|
|
||||||
|
|
||||||
## Update
|
## 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
- 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.
|
- 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.
|
- 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
|
## 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:
|
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
|
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
|
||||||
```
|
```
|
||||||
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
|
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.
|
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.
|
4. Click on *Start download* and let **Tube Archivist** to it's thing.
|
||||||
5. Enjoy your archived collection!
|
5. Enjoy your archived collection!
|
||||||
|
|
||||||
|
|
||||||
### Port Collisions
|
### 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.
|
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.
|
For more information on port collisions, check the docs.
|
||||||
|
|
||||||
## Common Errors
|
## Common Errors
|
||||||
Here is a list of common errors and their solutions.
|
Here is a list of common errors and their solutions.
|
||||||
|
|
||||||
### `vm.max_map_count`
|
### `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.
|
**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:
|
To temporary set the value run:
|
||||||
```
|
```
|
||||||
sudo sysctl -w vm.max_map_count=262144
|
sudo sysctl -w vm.max_map_count=262144
|
||||||
```
|
```
|
||||||
To apply the change permanently depends on your host operating system:
|
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`.
|
- 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 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.
|
- On any other platform look up in the documentation on how to pass kernel parameters.
|
||||||
|
|
||||||
|
|
||||||
### Permissions for elasticsearch
|
### Permissions for elasticsearch
|
||||||
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.
|
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:
|
To fix that issue, shutdown the container and on your host machine run:
|
||||||
```
|
```
|
||||||
chown 1000:0 -R /path/to/mount/point
|
chown 1000:0 -R /path/to/mount/point
|
||||||
```
|
```
|
||||||
This will match the permissions with the **UID** and **GID** of elasticsearch process 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
|
### 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`.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -141,17 +151,16 @@ If you are seeing errors like `failed to create shim: OCI runtime create failed`
|
|||||||
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
|
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
|
||||||
|
|
||||||
## Known limitations
|
## 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.
|
- 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.
|
- 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.
|
- There is no flexibility in naming of the media files.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
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:
|
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:
|
||||||
|
|
||||||
- [ ] User roles
|
|
||||||
- [ ] Audio download
|
- [ ] Audio download
|
||||||
- [ ] Podcast mode to serve channel as mp3
|
- [ ] Podcast mode to serve channel as mp3
|
||||||
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
- [ ] 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))
|
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
|
||||||
- [ ] Multi language support
|
- [ ] Multi language support
|
||||||
- [ ] Show total video downloaded vs total videos available in channel
|
- [ ] Show total video downloaded vs total videos available in channel
|
||||||
@@ -159,9 +168,12 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
|
|||||||
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
|
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
|
||||||
- [ ] Search comments
|
- [ ] Search comments
|
||||||
- [ ] Search download queue
|
- [ ] Search download queue
|
||||||
- [ ] Configure shorts, streams and video sizes per channel
|
- [ ] Per user videos/channel/playlists
|
||||||
|
|
||||||
Implemented:
|
Implemented:
|
||||||
|
- [X] Configure shorts, streams and video sizes per channel [2024-07-15]
|
||||||
|
- [X] User created playlists [2024-04-10]
|
||||||
|
- [X] User roles, aka read only user [2023-11-10]
|
||||||
- [X] Add statistics of index [2023-09-03]
|
- [X] Add statistics of index [2023-09-03]
|
||||||
- [X] Implement [Apprise](https://github.com/caronc/apprise) for notifications [2023-08-05]
|
- [X] Implement [Apprise](https://github.com/caronc/apprise) for notifications [2023-08-05]
|
||||||
- [X] Download video comments [2022-11-30]
|
- [X] Download video comments [2022-11-30]
|
||||||
@@ -188,7 +200,7 @@ Implemented:
|
|||||||
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
||||||
|
|
||||||
## User Scripts
|
## User Scripts
|
||||||
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
|
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -197,11 +209,13 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc
|
|||||||
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
|
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
|
||||||
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
|
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
|
||||||
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
|
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
|
||||||
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
|
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
|
||||||
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
|
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
|
||||||
|
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
|
||||||
|
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
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:
|
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
|
* [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.me](https://paypal.me/bbilly1) for a one time coffee
|
||||||
@@ -210,6 +224,8 @@ Second best way to support the development is to provide for caffeinated beverag
|
|||||||
|
|
||||||
## Notable mentions
|
## 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.
|
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.
|
||||||
|
* **xda-developers.com**: 5 obscure self-hosted services worth checking out - Tube Archivist - To save your essential YouTube videos, [2024-10-13][[link](https://www.xda-developers.com/obscure-self-hosted-services/)]
|
||||||
|
* **selfhosted.show**: why we're trying Tube Archivist, [2024-06-14][[link](https://selfhosted.show/125)]
|
||||||
* **ycombinator**: Tube Archivist on Hackernews front page, [2023-07-16][[link](https://news.ycombinator.com/item?id=36744395)]
|
* **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/)]
|
* **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/)]
|
* **noted.lol**: Dev Debrief, An Interview With the Developer of Tube Archivist, [2023-03-30] [[link](https://noted.lol/dev-debrief-tube-archivist/)]
|
||||||
@@ -221,13 +237,3 @@ This is a selection of places where this project has been featured on reddit, in
|
|||||||
* **reddit.com**: Celebrating TubeArchivist v0.1, [2022-01-09] [[link](https://www.reddit.com/r/selfhosted/comments/rzh084/celebrating_tubearchivist_v01/)]
|
* **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)]
|
* **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/)]
|
* **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>
|
|
||||||
|
|
||||||
|
|||||||
79
assets/tube-archivist-logo-dark.svg
Normal file
79
assets/tube-archivist-logo-dark.svg
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #039a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
clip-path: url(#clippath-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: #06131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
clip-path: url(#clippath-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
clip-path: url(#clippath-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
clip-path: url(#clippath);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<clipPath id="clippath">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-1">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-2">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-3">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="Artwork_1" class="st6">
|
||||||
|
<g class="st8">
|
||||||
|
<g class="st3">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Artwork_2">
|
||||||
|
<g class="st7">
|
||||||
|
<g class="st5">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
79
assets/tube-archivist-logo-light.svg
Normal file
79
assets/tube-archivist-logo-light.svg
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #039a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
clip-path: url(#clippath-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: #06131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
clip-path: url(#clippath-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
clip-path: url(#clippath-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
clip-path: url(#clippath);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<clipPath id="clippath">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-1">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-2">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-3">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="Artwork_1">
|
||||||
|
<g class="st8">
|
||||||
|
<g class="st3">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Artwork_2" class="st6">
|
||||||
|
<g class="st7">
|
||||||
|
<g class="st5">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
86
backend/README.md
Normal file
86
backend/README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Django Setup
|
||||||
|
|
||||||
|
## Apps
|
||||||
|
The backend is split up into the following apps.
|
||||||
|
|
||||||
|
### config
|
||||||
|
Root Django App. Doesn't define any views.
|
||||||
|
|
||||||
|
- Has main `settings.py`
|
||||||
|
- Has main `urls.py` responsible for routing to other apps
|
||||||
|
|
||||||
|
### common
|
||||||
|
Functionality shared between apps.
|
||||||
|
|
||||||
|
Defines views on the root `/api/*` path. Has base views to inherit from.
|
||||||
|
|
||||||
|
- Connections to ES and Redis
|
||||||
|
- Searching
|
||||||
|
- URL parser
|
||||||
|
- Collection of helper functions
|
||||||
|
|
||||||
|
### appsettings
|
||||||
|
Responsible for functionality from the settings pages.
|
||||||
|
|
||||||
|
Defines views at `/api/appsettings/*`.
|
||||||
|
|
||||||
|
- Index setup
|
||||||
|
- Reindexing
|
||||||
|
- Snapshots
|
||||||
|
- Filesystem Scan
|
||||||
|
- Manual import
|
||||||
|
|
||||||
|
### channel
|
||||||
|
Responsible for Channel Indexing functionality.
|
||||||
|
|
||||||
|
Defines views at `/api/channel/*` path.
|
||||||
|
|
||||||
|
### download
|
||||||
|
Implements download functionality with yt-dlp.
|
||||||
|
|
||||||
|
Defines views at `/api/download/*`.
|
||||||
|
|
||||||
|
- Download videos
|
||||||
|
- Queue management
|
||||||
|
- Thumbnails
|
||||||
|
- Subscriptions
|
||||||
|
|
||||||
|
### playlist
|
||||||
|
Implements playlist functionality.
|
||||||
|
|
||||||
|
Defines views at `/api/playlist/*`.
|
||||||
|
|
||||||
|
- Index Playlists
|
||||||
|
- Manual Playlists
|
||||||
|
|
||||||
|
### stats
|
||||||
|
Builds aggregations views for the statistics dashboard.
|
||||||
|
|
||||||
|
Defines views at `/api/stats/*`.
|
||||||
|
|
||||||
|
### task
|
||||||
|
Defines tasks for Celery.
|
||||||
|
|
||||||
|
Defines views at `/api/task/*`.
|
||||||
|
|
||||||
|
- Has main `tasks.py` with all shared_task definitions
|
||||||
|
- Has `CustomPeriodicTask` model
|
||||||
|
- Implements apprise notifications links
|
||||||
|
- Implements schedule functionality
|
||||||
|
|
||||||
|
### user
|
||||||
|
Implements user and auth functionality.
|
||||||
|
|
||||||
|
Defines views at `/api/config/*`.
|
||||||
|
|
||||||
|
- Defines custom `Account` model
|
||||||
|
|
||||||
|
### video
|
||||||
|
Index functionality for videos.
|
||||||
|
|
||||||
|
Defines views at `/api/video/*`.
|
||||||
|
|
||||||
|
- Index videos
|
||||||
|
- Index comments
|
||||||
|
- Index/download subtitles
|
||||||
|
- Media stream parsing
|
||||||
@@ -75,6 +75,15 @@
|
|||||||
},
|
},
|
||||||
"integrate_sponsorblock": {
|
"integrate_sponsorblock": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subscriptions_channel_size": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"subscriptions_live_channel_size": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"subscriptions_shorts_channel_size": {
|
||||||
|
"type": "long"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +178,15 @@
|
|||||||
},
|
},
|
||||||
"integrate_sponsorblock": {
|
"integrate_sponsorblock": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subscriptions_channel_size": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"subscriptions_live_channel_size": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"subscriptions_shorts_channel_size": {
|
||||||
|
"type": "long"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,7 +239,8 @@
|
|||||||
"type": "keyword"
|
"type": "keyword"
|
||||||
},
|
},
|
||||||
"published": {
|
"published": {
|
||||||
"type": "date"
|
"type": "date",
|
||||||
|
"format": "epoch_second||strict_date_optional_time"
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -666,4 +685,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
133
backend/appsettings/serializers.py
Normal file
133
backend/appsettings/serializers.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""appsettings erializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from common.serializers import ValidateUnknownFieldsMixin
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class BackupFileSerializer(serializers.Serializer):
|
||||||
|
"""serialize backup file"""
|
||||||
|
|
||||||
|
filename = serializers.CharField()
|
||||||
|
file_path = serializers.CharField()
|
||||||
|
file_size = serializers.IntegerField()
|
||||||
|
timestamp = serializers.CharField()
|
||||||
|
reason = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigSubSerializer(
|
||||||
|
ValidateUnknownFieldsMixin, serializers.Serializer
|
||||||
|
):
|
||||||
|
"""serialize app config subscriptions"""
|
||||||
|
|
||||||
|
channel_size = serializers.IntegerField(required=False)
|
||||||
|
live_channel_size = serializers.IntegerField(required=False)
|
||||||
|
shorts_channel_size = serializers.IntegerField(required=False)
|
||||||
|
auto_start = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigDownloadsSerializer(
|
||||||
|
ValidateUnknownFieldsMixin, serializers.Serializer
|
||||||
|
):
|
||||||
|
"""serialize app config downloads config"""
|
||||||
|
|
||||||
|
limit_speed = serializers.IntegerField(allow_null=True)
|
||||||
|
sleep_interval = serializers.IntegerField(allow_null=True)
|
||||||
|
autodelete_days = serializers.IntegerField(allow_null=True)
|
||||||
|
format = serializers.CharField(allow_null=True)
|
||||||
|
format_sort = serializers.CharField(allow_null=True)
|
||||||
|
add_metadata = serializers.BooleanField()
|
||||||
|
add_thumbnail = serializers.BooleanField()
|
||||||
|
subtitle = serializers.CharField(allow_null=True)
|
||||||
|
subtitle_source = serializers.ChoiceField(
|
||||||
|
choices=["auto", "user"], allow_null=True
|
||||||
|
)
|
||||||
|
subtitle_index = serializers.BooleanField()
|
||||||
|
comment_max = serializers.CharField(allow_null=True)
|
||||||
|
comment_sort = serializers.ChoiceField(
|
||||||
|
choices=["top", "new"], allow_null=True
|
||||||
|
)
|
||||||
|
cookie_import = serializers.BooleanField()
|
||||||
|
potoken = serializers.BooleanField()
|
||||||
|
throttledratelimit = serializers.IntegerField(allow_null=True)
|
||||||
|
extractor_lang = serializers.CharField(allow_null=True)
|
||||||
|
integrate_ryd = serializers.BooleanField()
|
||||||
|
integrate_sponsorblock = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigAppSerializer(
|
||||||
|
ValidateUnknownFieldsMixin, serializers.Serializer
|
||||||
|
):
|
||||||
|
"""serialize app config"""
|
||||||
|
|
||||||
|
enable_snapshot = serializers.BooleanField()
|
||||||
|
enable_cast = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigSerializer(ValidateUnknownFieldsMixin, serializers.Serializer):
|
||||||
|
"""serialize appconfig"""
|
||||||
|
|
||||||
|
subscriptions = AppConfigSubSerializer(required=False)
|
||||||
|
downloads = AppConfigDownloadsSerializer(required=False)
|
||||||
|
application = AppConfigAppSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieValidationSerializer(serializers.Serializer):
|
||||||
|
"""serialize cookie validation response"""
|
||||||
|
|
||||||
|
cookie_enabled = serializers.BooleanField()
|
||||||
|
status = serializers.BooleanField(required=False)
|
||||||
|
validated = serializers.IntegerField(required=False)
|
||||||
|
validated_str = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieUpdateSerializer(serializers.Serializer):
|
||||||
|
"""serialize cookie to update"""
|
||||||
|
|
||||||
|
cookie = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PoTokenSerializer(serializers.Serializer):
|
||||||
|
"""serialize PO token"""
|
||||||
|
|
||||||
|
potoken = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotItemSerializer(serializers.Serializer):
|
||||||
|
"""serialize snapshot response"""
|
||||||
|
|
||||||
|
id = serializers.CharField()
|
||||||
|
state = serializers.CharField()
|
||||||
|
es_version = serializers.CharField()
|
||||||
|
start_date = serializers.CharField()
|
||||||
|
end_date = serializers.CharField()
|
||||||
|
end_stamp = serializers.IntegerField()
|
||||||
|
duration_s = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotListSerializer(serializers.Serializer):
|
||||||
|
"""serialize snapshot list response"""
|
||||||
|
|
||||||
|
next_exec = serializers.IntegerField()
|
||||||
|
next_exec_str = serializers.CharField()
|
||||||
|
expire_after = serializers.CharField()
|
||||||
|
snapshots = SnapshotItemSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotCreateResponseSerializer(serializers.Serializer):
|
||||||
|
"""serialize new snapshot creating response"""
|
||||||
|
|
||||||
|
snapshot_name = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotRestoreResponseSerializer(serializers.Serializer):
|
||||||
|
"""serialize snapshot restore response"""
|
||||||
|
|
||||||
|
accepted = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponseSerializer(serializers.Serializer):
|
||||||
|
"""serialize token response"""
|
||||||
|
|
||||||
|
token = serializers.CharField()
|
||||||
@@ -10,10 +10,10 @@ import os
|
|||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.ta.config import AppConfig
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.ta.helper import get_mapping, ignore_filelist
|
from common.src.helper import get_mapping, ignore_filelist
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from task.models import CustomPeriodicTask
|
||||||
|
|
||||||
|
|
||||||
class ElasticBackup:
|
class ElasticBackup:
|
||||||
@@ -23,8 +23,7 @@ class ElasticBackup:
|
|||||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||||
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
|
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
|
||||||
|
|
||||||
def __init__(self, reason=False, task=False):
|
def __init__(self, reason=False, task=False) -> None:
|
||||||
self.config = AppConfig().config
|
|
||||||
self.timestamp = datetime.now().strftime("%Y%m%d")
|
self.timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
self.index_config = get_mapping()
|
self.index_config = get_mapping()
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
@@ -138,6 +137,8 @@ class ElasticBackup:
|
|||||||
elif len(file_split) == 3:
|
elif len(file_split) == 3:
|
||||||
timestamp = file_split[1]
|
timestamp = file_split[1]
|
||||||
reason = file_split[2].strip(".zip")
|
reason = file_split[2].strip(".zip")
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -197,7 +198,12 @@ class ElasticBackup:
|
|||||||
|
|
||||||
def rotate_backup(self):
|
def rotate_backup(self):
|
||||||
"""delete old backups if needed"""
|
"""delete old backups if needed"""
|
||||||
rotate = self.config["scheduler"]["run_backup_rotate"]
|
try:
|
||||||
|
task = CustomPeriodicTask.objects.get(name="run_backup")
|
||||||
|
except CustomPeriodicTask.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
rotate = task.task_config.get("rotate")
|
||||||
if not rotate:
|
if not rotate:
|
||||||
return
|
return
|
||||||
|
|
||||||
252
backend/appsettings/src/config.py
Normal file
252
backend/appsettings/src/config.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- read and write config
|
||||||
|
- load config variables into redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
from random import randint
|
||||||
|
from time import sleep
|
||||||
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from appsettings.src.snapshot import ElasticSnapshot
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionsConfigType(TypedDict):
|
||||||
|
"""describes subscriptions config"""
|
||||||
|
|
||||||
|
channel_size: int
|
||||||
|
live_channel_size: int
|
||||||
|
shorts_channel_size: int
|
||||||
|
auto_start: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadsConfigType(TypedDict):
|
||||||
|
"""describes downloads config"""
|
||||||
|
|
||||||
|
limit_speed: int | None
|
||||||
|
sleep_interval: int | None
|
||||||
|
autodelete_days: int | None
|
||||||
|
format: str | None
|
||||||
|
format_sort: str | None
|
||||||
|
add_metadata: bool
|
||||||
|
add_thumbnail: bool
|
||||||
|
subtitle: str | None
|
||||||
|
subtitle_source: Literal["user", "auto"] | None
|
||||||
|
subtitle_index: bool
|
||||||
|
comment_max: str | None
|
||||||
|
comment_sort: Literal["top", "new"] | None
|
||||||
|
cookie_import: bool
|
||||||
|
potoken: bool
|
||||||
|
throttledratelimit: int | None
|
||||||
|
extractor_lang: str | None
|
||||||
|
integrate_ryd: bool
|
||||||
|
integrate_sponsorblock: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationConfigType(TypedDict):
|
||||||
|
"""describes application config"""
|
||||||
|
|
||||||
|
enable_snapshot: bool
|
||||||
|
enable_cast: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigType(TypedDict):
|
||||||
|
"""combined app config type"""
|
||||||
|
|
||||||
|
subscriptions: SubscriptionsConfigType
|
||||||
|
downloads: DownloadsConfigType
|
||||||
|
application: ApplicationConfigType
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig:
|
||||||
|
"""handle application variables"""
|
||||||
|
|
||||||
|
ES_PATH = "ta_config/_doc/appsettings"
|
||||||
|
ES_UPDATE_PATH = "ta_config/_update/appsettings"
|
||||||
|
CONFIG_DEFAULTS: AppConfigType = {
|
||||||
|
"subscriptions": {
|
||||||
|
"channel_size": 50,
|
||||||
|
"live_channel_size": 50,
|
||||||
|
"shorts_channel_size": 50,
|
||||||
|
"auto_start": False,
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"limit_speed": None,
|
||||||
|
"sleep_interval": 10,
|
||||||
|
"autodelete_days": None,
|
||||||
|
"format": None,
|
||||||
|
"format_sort": None,
|
||||||
|
"add_metadata": False,
|
||||||
|
"add_thumbnail": False,
|
||||||
|
"subtitle": None,
|
||||||
|
"subtitle_source": None,
|
||||||
|
"subtitle_index": False,
|
||||||
|
"comment_max": None,
|
||||||
|
"comment_sort": "top",
|
||||||
|
"cookie_import": False,
|
||||||
|
"potoken": False,
|
||||||
|
"throttledratelimit": None,
|
||||||
|
"extractor_lang": None,
|
||||||
|
"integrate_ryd": False,
|
||||||
|
"integrate_sponsorblock": False,
|
||||||
|
},
|
||||||
|
"application": {
|
||||||
|
"enable_snapshot": True,
|
||||||
|
"enable_cast": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = self.get_config()
|
||||||
|
|
||||||
|
def get_config(self) -> AppConfigType:
|
||||||
|
"""get config from ES"""
|
||||||
|
response, status_code = ElasticWrap(self.ES_PATH).get()
|
||||||
|
if not status_code == 200:
|
||||||
|
raise ValueError(f"no config found at {self.ES_PATH}")
|
||||||
|
|
||||||
|
return response["_source"]
|
||||||
|
|
||||||
|
def update_config(self, data: dict) -> AppConfigType:
|
||||||
|
"""update single config value"""
|
||||||
|
new_config = self.config.copy()
|
||||||
|
for key, value in data.items():
|
||||||
|
if (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and key in new_config
|
||||||
|
and isinstance(new_config[key], dict)
|
||||||
|
):
|
||||||
|
new_config[key].update(value)
|
||||||
|
else:
|
||||||
|
new_config[key] = value
|
||||||
|
|
||||||
|
response, status_code = ElasticWrap(self.ES_PATH).post(new_config)
|
||||||
|
if not status_code == 200:
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
self.config = new_config
|
||||||
|
|
||||||
|
return new_config
|
||||||
|
|
||||||
|
def post_process_updated(self, data: dict) -> None:
|
||||||
|
"""apply hooks for some config keys"""
|
||||||
|
for config_value, updated_value in data:
|
||||||
|
if config_value == "application.enable_snapshot" and updated_value:
|
||||||
|
ElasticSnapshot().setup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fail_message(message_line):
|
||||||
|
"""notify our failure"""
|
||||||
|
key = "message:setting"
|
||||||
|
message = {
|
||||||
|
"status": key,
|
||||||
|
"group": "setting:application",
|
||||||
|
"level": "error",
|
||||||
|
"title": "Cookie import failed",
|
||||||
|
"messages": [message_line],
|
||||||
|
"id": "0000",
|
||||||
|
}
|
||||||
|
RedisArchivist().set_message(key, message=message, expire=True)
|
||||||
|
|
||||||
|
def sync_defaults(self):
|
||||||
|
"""sync defaults at startup, needs to be called with __new__"""
|
||||||
|
return ElasticWrap(self.ES_PATH).post(self.CONFIG_DEFAULTS)
|
||||||
|
|
||||||
|
def add_new_defaults(self) -> list[str]:
|
||||||
|
"""add new default config values to ES, called at startup"""
|
||||||
|
updated = []
|
||||||
|
for key, value in self.CONFIG_DEFAULTS.items():
|
||||||
|
if key not in self.config:
|
||||||
|
# complete new key
|
||||||
|
self.update_config({key: value})
|
||||||
|
updated.append(str({key: value}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for sub_key, sub_value in value.items(): # type: ignore
|
||||||
|
if sub_key not in self.config[key]:
|
||||||
|
# new partial key
|
||||||
|
to_update = {key: {sub_key: sub_value}}
|
||||||
|
self.update_config(to_update)
|
||||||
|
updated.append(str(to_update))
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVersion:
|
||||||
|
"""compare local version with remote version"""
|
||||||
|
|
||||||
|
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
|
||||||
|
NEW_KEY = "versioncheck:new"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.local_version: str = settings.TA_VERSION
|
||||||
|
self.is_unstable: bool = settings.TA_VERSION.endswith("-unstable")
|
||||||
|
self.remote_version: str = ""
|
||||||
|
self.is_breaking: bool = False
|
||||||
|
|
||||||
|
def check(self) -> None:
|
||||||
|
"""check version"""
|
||||||
|
print(f"[{self.local_version}]: look for updates")
|
||||||
|
self.get_remote_version()
|
||||||
|
new_version = self._has_update()
|
||||||
|
if new_version:
|
||||||
|
message = {
|
||||||
|
"status": True,
|
||||||
|
"version": new_version,
|
||||||
|
"is_breaking": self.is_breaking,
|
||||||
|
}
|
||||||
|
RedisArchivist().set_message(self.NEW_KEY, message)
|
||||||
|
print(f"[{self.local_version}]: found new version {new_version}")
|
||||||
|
|
||||||
|
def get_local_version(self) -> str:
|
||||||
|
"""read version from local"""
|
||||||
|
return self.local_version
|
||||||
|
|
||||||
|
def get_remote_version(self) -> None:
|
||||||
|
"""read version from remote"""
|
||||||
|
sleep(randint(0, 60))
|
||||||
|
response = requests.get(self.REMOTE_URL, timeout=20).json()
|
||||||
|
self.remote_version = response["release_version"]
|
||||||
|
self.is_breaking = response["breaking_changes"]
|
||||||
|
|
||||||
|
def _has_update(self) -> str | bool:
|
||||||
|
"""check if there is an update"""
|
||||||
|
remote_parsed = self._parse_version(self.remote_version)
|
||||||
|
local_parsed = self._parse_version(self.local_version)
|
||||||
|
if remote_parsed > local_parsed:
|
||||||
|
return self.remote_version
|
||||||
|
|
||||||
|
if self.is_unstable and local_parsed == remote_parsed:
|
||||||
|
return self.remote_version
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_version(version) -> tuple[int, ...]:
|
||||||
|
"""return version parts"""
|
||||||
|
clean = version.rstrip("-unstable").lstrip("v")
|
||||||
|
return tuple((int(i) for i in clean.split(".")))
|
||||||
|
|
||||||
|
def is_updated(self) -> str | bool:
|
||||||
|
"""check if update happened in the mean time"""
|
||||||
|
message = self.get_update()
|
||||||
|
if not message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
local_parsed = self._parse_version(self.local_version)
|
||||||
|
message_parsed = self._parse_version(message.get("version"))
|
||||||
|
|
||||||
|
if local_parsed >= message_parsed:
|
||||||
|
RedisArchivist().del_message(self.NEW_KEY)
|
||||||
|
return settings.TA_VERSION
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_update(self) -> dict | None:
|
||||||
|
"""return new version dict if available"""
|
||||||
|
message = RedisArchivist().get_message_dict(self.NEW_KEY)
|
||||||
|
return message or None
|
||||||
@@ -5,11 +5,11 @@ Functionality:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.index.comments import CommentList
|
from common.src.es_connect import IndexPaginate
|
||||||
from home.src.index.video import YoutubeVideo, index_new_video
|
from common.src.helper import ignore_filelist
|
||||||
from home.src.ta.helper import ignore_filelist
|
from video.src.comments import CommentList
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from video.src.index import YoutubeVideo, index_new_video
|
||||||
|
|
||||||
|
|
||||||
class Scanner:
|
class Scanner:
|
||||||
@@ -56,7 +56,6 @@ class Scanner:
|
|||||||
"""apply all changes"""
|
"""apply all changes"""
|
||||||
self.delete()
|
self.delete()
|
||||||
self.index()
|
self.index()
|
||||||
self.url_fix()
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""delete videos from index"""
|
"""delete videos from index"""
|
||||||
@@ -89,36 +88,6 @@ class Scanner:
|
|||||||
)
|
)
|
||||||
index_new_video(youtube_id)
|
index_new_video(youtube_id)
|
||||||
|
|
||||||
CommentList(self.to_index, task=self.task).index()
|
comment_list = CommentList(task=self.task)
|
||||||
|
comment_list.add(video_ids=list(self.to_index))
|
||||||
def url_fix(self) -> None:
|
comment_list.index()
|
||||||
"""
|
|
||||||
update path v0.3.6 to v0.3.7
|
|
||||||
fix url not matching channel-videoid pattern
|
|
||||||
"""
|
|
||||||
bool_must = (
|
|
||||||
"doc['media_url'].value == "
|
|
||||||
+ "(doc['channel.channel_id'].value + '/' + "
|
|
||||||
+ "doc['youtube_id'].value) + '.mp4'"
|
|
||||||
)
|
|
||||||
to_update = (
|
|
||||||
"ctx._source['media_url'] = "
|
|
||||||
+ "ctx._source.channel['channel_id'] + '/' + "
|
|
||||||
+ "ctx._source['youtube_id'] + '.mp4'"
|
|
||||||
)
|
|
||||||
data = {
|
|
||||||
"query": {
|
|
||||||
"bool": {
|
|
||||||
"must_not": [{"script": {"script": {"source": bool_must}}}]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"script": {"source": to_update},
|
|
||||||
}
|
|
||||||
response, _ = ElasticWrap("ta_video/_update_by_query").post(data=data)
|
|
||||||
updated = response.get("updates")
|
|
||||||
if updated:
|
|
||||||
print(f"updated {updated} bad media_url")
|
|
||||||
if self.task:
|
|
||||||
self.task.send_progress(
|
|
||||||
[f"Updated {updated} wrong media urls."]
|
|
||||||
)
|
|
||||||
@@ -5,11 +5,11 @@ functionality:
|
|||||||
- backup and restore metadata
|
- backup and restore metadata
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from home.src.es.backup import ElasticBackup
|
from appsettings.src.backup import ElasticBackup
|
||||||
from home.src.es.connect import ElasticWrap
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.es.snapshot import ElasticSnapshot
|
from appsettings.src.snapshot import ElasticSnapshot
|
||||||
from home.src.ta.config import AppConfig
|
from common.src.es_connect import ElasticWrap
|
||||||
from home.src.ta.helper import get_mapping
|
from common.src.helper import get_mapping
|
||||||
|
|
||||||
|
|
||||||
class ElasticIndex:
|
class ElasticIndex:
|
||||||
@@ -111,6 +111,8 @@ class ElasticIndex:
|
|||||||
elif method == "restore":
|
elif method == "restore":
|
||||||
source = f"ta_{self.index_name}_backup"
|
source = f"ta_{self.index_name}_backup"
|
||||||
destination = f"ta_{self.index_name}"
|
destination = f"ta_{self.index_name}"
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid method, expected 'backup' or 'restore'")
|
||||||
|
|
||||||
data = {"source": {"index": source}, "dest": {"index": destination}}
|
data = {"source": {"index": source}, "dest": {"index": destination}}
|
||||||
_, _ = ElasticWrap("_reindex?refresh=true").post(data=data)
|
_, _ = ElasticWrap("_reindex?refresh=true").post(data=data)
|
||||||
@@ -199,7 +201,15 @@ class ElasitIndexWrap:
|
|||||||
if self.backup_run:
|
if self.backup_run:
|
||||||
return
|
return
|
||||||
|
|
||||||
config = AppConfig().config
|
try:
|
||||||
|
config = AppConfig().config
|
||||||
|
except ValueError:
|
||||||
|
# create defaults in ES if config not found
|
||||||
|
print("AppConfig not found, creating defaults...")
|
||||||
|
handler = AppConfig.__new__(AppConfig)
|
||||||
|
handler.sync_defaults()
|
||||||
|
config = AppConfig.CONFIG_DEFAULTS
|
||||||
|
|
||||||
if config["application"]["enable_snapshot"]:
|
if config["application"]["enable_snapshot"]:
|
||||||
# take snapshot if enabled
|
# take snapshot if enabled
|
||||||
ElasticSnapshot().take_snapshot_now(wait=True)
|
ElasticSnapshot().take_snapshot_now(wait=True)
|
||||||
@@ -11,13 +11,13 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.index.comments import CommentList
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.index.video import YoutubeVideo
|
from common.src.helper import ignore_filelist
|
||||||
from home.src.ta.config import AppConfig
|
from download.src.thumbnails import ThumbManager
|
||||||
from home.src.ta.helper import ignore_filelist
|
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from video.src.comments import CommentList
|
||||||
|
from video.src.index import YoutubeVideo
|
||||||
from yt_dlp.utils import ISO639Utils
|
from yt_dlp.utils import ISO639Utils
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ class ImportFolderScanner:
|
|||||||
- convert if needed
|
- convert if needed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CONFIG = AppConfig().config
|
|
||||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||||
IMPORT_DIR = os.path.join(CACHE_DIR, "import")
|
IMPORT_DIR = os.path.join(CACHE_DIR, "import")
|
||||||
|
|
||||||
@@ -129,6 +128,7 @@ class ImportFolderScanner:
|
|||||||
|
|
||||||
def process_videos(self):
|
def process_videos(self):
|
||||||
"""loop through all videos"""
|
"""loop through all videos"""
|
||||||
|
config = AppConfig().config
|
||||||
for idx, current_video in enumerate(self.to_import):
|
for idx, current_video in enumerate(self.to_import):
|
||||||
if not current_video["media"]:
|
if not current_video["media"]:
|
||||||
print(f"{current_video}: no matching media file found.")
|
print(f"{current_video}: no matching media file found.")
|
||||||
@@ -144,10 +144,12 @@ class ImportFolderScanner:
|
|||||||
self._convert_video(current_video)
|
self._convert_video(current_video)
|
||||||
print(f"manual import: {current_video}")
|
print(f"manual import: {current_video}")
|
||||||
|
|
||||||
ManualImport(current_video, self.CONFIG).run()
|
ManualImport(current_video, config).run()
|
||||||
|
|
||||||
video_ids = [i["video_id"] for i in self.to_import]
|
video_ids = [i["video_id"] for i in self.to_import]
|
||||||
CommentList(video_ids, task=self.task).index()
|
comment_list = CommentList(task=self.task)
|
||||||
|
comment_list.add(video_ids=video_ids)
|
||||||
|
comment_list.index()
|
||||||
|
|
||||||
def _notify(self, idx, current_video):
|
def _notify(self, idx, current_video):
|
||||||
"""send notification back to task"""
|
"""send notification back to task"""
|
||||||
@@ -405,8 +407,11 @@ class ManualImport:
|
|||||||
media_path=self.current_video["media"],
|
media_path=self.current_video["media"],
|
||||||
)
|
)
|
||||||
if not video.json_data:
|
if not video.json_data:
|
||||||
print(f"{video_id}: manual import failed, and no metadata found.")
|
message = (
|
||||||
raise ValueError
|
f"{video_id}: manual import failed, and no metadata found."
|
||||||
|
)
|
||||||
|
print(message)
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
|
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
|
||||||
video.upload_to_es()
|
video.upload_to_es()
|
||||||
@@ -7,26 +7,36 @@ functionality:
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import sleep
|
from typing import Callable, TypedDict
|
||||||
|
|
||||||
from home.src.download.queue import PendingList
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.download.subscriptions import ChannelSubscription
|
from channel.src.index import YoutubeChannel
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.download.yt_dlp_base import CookieHandler
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.helper import rand_sleep
|
||||||
from home.src.index.channel import YoutubeChannel
|
from common.src.ta_redis import RedisQueue
|
||||||
from home.src.index.comments import Comments
|
from download.src.subscriptions import ChannelSubscription
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from download.src.thumbnails import ThumbManager
|
||||||
from home.src.index.video import YoutubeVideo
|
from download.src.yt_dlp_base import CookieHandler
|
||||||
from home.src.ta.config import AppConfig
|
from playlist.src.index import YoutubePlaylist
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from task.models import CustomPeriodicTask
|
||||||
from home.src.ta.ta_redis import RedisQueue
|
from video.src.comments import Comments
|
||||||
|
from video.src.index import YoutubeVideo
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexConfigType(TypedDict):
|
||||||
|
"""represents config type"""
|
||||||
|
|
||||||
|
index_name: str
|
||||||
|
queue_name: str
|
||||||
|
active_key: str
|
||||||
|
refresh_key: str
|
||||||
|
|
||||||
|
|
||||||
class ReindexBase:
|
class ReindexBase:
|
||||||
"""base config class for reindex task"""
|
"""base config class for reindex task"""
|
||||||
|
|
||||||
REINDEX_CONFIG = {
|
REINDEX_CONFIG: dict[str, ReindexConfigType] = {
|
||||||
"video": {
|
"video": {
|
||||||
"index_name": "ta_video",
|
"index_name": "ta_video",
|
||||||
"queue_name": "reindex:ta_video",
|
"queue_name": "reindex:ta_video",
|
||||||
@@ -53,25 +63,36 @@ class ReindexBase:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.now = int(datetime.now().timestamp())
|
self.now = int(datetime.now().timestamp())
|
||||||
self.total = None
|
|
||||||
|
|
||||||
def populate(self, all_ids, reindex_config):
|
def populate(self, all_ids, reindex_config: ReindexConfigType):
|
||||||
"""add all to reindex ids to redis queue"""
|
"""add all to reindex ids to redis queue"""
|
||||||
if not all_ids:
|
if not all_ids:
|
||||||
return
|
return
|
||||||
|
|
||||||
RedisQueue(queue_name=reindex_config["queue_name"]).add_list(all_ids)
|
RedisQueue(queue_name=reindex_config["queue_name"]).add_list(all_ids)
|
||||||
self.total = None
|
|
||||||
|
|
||||||
|
|
||||||
class ReindexPopulate(ReindexBase):
|
class ReindexPopulate(ReindexBase):
|
||||||
"""add outdated and recent documents to reindex queue"""
|
"""add outdated and recent documents to reindex queue"""
|
||||||
|
|
||||||
|
INTERVAL_DEFAIULT: int = 90
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.interval = self.config["scheduler"]["check_reindex_days"]
|
self.interval = self.INTERVAL_DEFAIULT
|
||||||
|
|
||||||
def add_recent(self):
|
def get_interval(self) -> None:
|
||||||
|
"""get reindex days interval from task"""
|
||||||
|
try:
|
||||||
|
task = CustomPeriodicTask.objects.get(name="check_reindex")
|
||||||
|
except CustomPeriodicTask.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
task_config = task.task_config
|
||||||
|
if task_config.get("days"):
|
||||||
|
self.interval = task_config.get("days")
|
||||||
|
|
||||||
|
def add_recent(self) -> None:
|
||||||
"""add recent videos to refresh"""
|
"""add recent videos to refresh"""
|
||||||
gte = datetime.fromtimestamp(self.now - self.DAYS3).date().isoformat()
|
gte = datetime.fromtimestamp(self.now - self.DAYS3).date().isoformat()
|
||||||
must_list = [
|
must_list = [
|
||||||
@@ -89,10 +110,10 @@ class ReindexPopulate(ReindexBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
all_ids = [i["_source"]["youtube_id"] for i in hits]
|
all_ids = [i["_source"]["youtube_id"] for i in hits]
|
||||||
reindex_config = self.REINDEX_CONFIG.get("video")
|
reindex_config: ReindexConfigType = self.REINDEX_CONFIG["video"]
|
||||||
self.populate(all_ids, reindex_config)
|
self.populate(all_ids, reindex_config)
|
||||||
|
|
||||||
def add_outdated(self):
|
def add_outdated(self) -> None:
|
||||||
"""add outdated documents"""
|
"""add outdated documents"""
|
||||||
for reindex_config in self.REINDEX_CONFIG.values():
|
for reindex_config in self.REINDEX_CONFIG.values():
|
||||||
total_hits = self._get_total_hits(reindex_config)
|
total_hits = self._get_total_hits(reindex_config)
|
||||||
@@ -101,7 +122,7 @@ class ReindexPopulate(ReindexBase):
|
|||||||
self.populate(all_ids, reindex_config)
|
self.populate(all_ids, reindex_config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_total_hits(reindex_config):
|
def _get_total_hits(reindex_config: ReindexConfigType) -> int:
|
||||||
"""get total hits from index"""
|
"""get total hits from index"""
|
||||||
index_name = reindex_config["index_name"]
|
index_name = reindex_config["index_name"]
|
||||||
active_key = reindex_config["active_key"]
|
active_key = reindex_config["active_key"]
|
||||||
@@ -113,7 +134,7 @@ class ReindexPopulate(ReindexBase):
|
|||||||
|
|
||||||
return len(total)
|
return len(total)
|
||||||
|
|
||||||
def _get_daily_should(self, total_hits):
|
def _get_daily_should(self, total_hits: int) -> int:
|
||||||
"""calc how many should reindex daily"""
|
"""calc how many should reindex daily"""
|
||||||
daily_should = int((total_hits // self.interval + 1) * self.MULTIPLY)
|
daily_should = int((total_hits // self.interval + 1) * self.MULTIPLY)
|
||||||
if daily_should >= 10000:
|
if daily_should >= 10000:
|
||||||
@@ -121,7 +142,9 @@ class ReindexPopulate(ReindexBase):
|
|||||||
|
|
||||||
return daily_should
|
return daily_should
|
||||||
|
|
||||||
def _get_outdated_ids(self, reindex_config, daily_should):
|
def _get_outdated_ids(
|
||||||
|
self, reindex_config: ReindexConfigType, daily_should: int
|
||||||
|
) -> list[str]:
|
||||||
"""get outdated from index_name"""
|
"""get outdated from index_name"""
|
||||||
index_name = reindex_config["index_name"]
|
index_name = reindex_config["index_name"]
|
||||||
refresh_key = reindex_config["refresh_key"]
|
refresh_key = reindex_config["refresh_key"]
|
||||||
@@ -158,7 +181,7 @@ class ReindexManual(ReindexBase):
|
|||||||
self.extract_videos = extract_videos
|
self.extract_videos = extract_videos
|
||||||
self.data = False
|
self.data = False
|
||||||
|
|
||||||
def extract_data(self, data):
|
def extract_data(self, data) -> None:
|
||||||
"""process data"""
|
"""process data"""
|
||||||
self.data = data
|
self.data = data
|
||||||
for key, values in self.data.items():
|
for key, values in self.data.items():
|
||||||
@@ -169,7 +192,9 @@ class ReindexManual(ReindexBase):
|
|||||||
|
|
||||||
self.process_index(reindex_config, values)
|
self.process_index(reindex_config, values)
|
||||||
|
|
||||||
def process_index(self, index_config, values):
|
def process_index(
|
||||||
|
self, index_config: ReindexConfigType, values: list[str]
|
||||||
|
) -> None:
|
||||||
"""process values per index"""
|
"""process values per index"""
|
||||||
index_name = index_config["index_name"]
|
index_name = index_config["index_name"]
|
||||||
if index_name == "ta_video":
|
if index_name == "ta_video":
|
||||||
@@ -179,32 +204,35 @@ class ReindexManual(ReindexBase):
|
|||||||
elif index_name == "ta_playlist":
|
elif index_name == "ta_playlist":
|
||||||
self._add_playlists(values)
|
self._add_playlists(values)
|
||||||
|
|
||||||
def _add_videos(self, values):
|
def _add_videos(self, values: list[str]) -> None:
|
||||||
"""add list of videos to reindex queue"""
|
"""add list of videos to reindex queue"""
|
||||||
if not values:
|
if not values:
|
||||||
return
|
return
|
||||||
|
|
||||||
RedisQueue("reindex:ta_video").add_list(values)
|
queue_name = self.REINDEX_CONFIG["video"]["queue_name"]
|
||||||
|
RedisQueue(queue_name).add_list(values)
|
||||||
|
|
||||||
def _add_channels(self, values):
|
def _add_channels(self, values: list[str]) -> None:
|
||||||
"""add list of channels to reindex queue"""
|
"""add list of channels to reindex queue"""
|
||||||
RedisQueue("reindex:ta_channel").add_list(values)
|
queue_name = self.REINDEX_CONFIG["channel"]["queue_name"]
|
||||||
|
RedisQueue(queue_name).add_list(values)
|
||||||
|
|
||||||
if self.extract_videos:
|
if self.extract_videos:
|
||||||
for channel_id in values:
|
for channel_id in values:
|
||||||
all_videos = self._get_channel_videos(channel_id)
|
all_videos = self._get_channel_videos(channel_id)
|
||||||
self._add_videos(all_videos)
|
self._add_videos(all_videos)
|
||||||
|
|
||||||
def _add_playlists(self, values):
|
def _add_playlists(self, values: list[str]) -> None:
|
||||||
"""add list of playlists to reindex queue"""
|
"""add list of playlists to reindex queue"""
|
||||||
RedisQueue("reindex:ta_playlist").add_list(values)
|
queue_name = self.REINDEX_CONFIG["playlist"]["queue_name"]
|
||||||
|
RedisQueue(queue_name).add_list(values)
|
||||||
|
|
||||||
if self.extract_videos:
|
if self.extract_videos:
|
||||||
for playlist_id in values:
|
for playlist_id in values:
|
||||||
all_videos = self._get_playlist_videos(playlist_id)
|
all_videos = self._get_playlist_videos(playlist_id)
|
||||||
self._add_videos(all_videos)
|
self._add_videos(all_videos)
|
||||||
|
|
||||||
def _get_channel_videos(self, channel_id):
|
def _get_channel_videos(self, channel_id: str) -> list[str]:
|
||||||
"""get all videos from channel"""
|
"""get all videos from channel"""
|
||||||
data = {
|
data = {
|
||||||
"query": {"term": {"channel.channel_id": {"value": channel_id}}},
|
"query": {"term": {"channel.channel_id": {"value": channel_id}}},
|
||||||
@@ -213,7 +241,7 @@ class ReindexManual(ReindexBase):
|
|||||||
all_results = IndexPaginate("ta_video", data).get_results()
|
all_results = IndexPaginate("ta_video", data).get_results()
|
||||||
return [i["youtube_id"] for i in all_results]
|
return [i["youtube_id"] for i in all_results]
|
||||||
|
|
||||||
def _get_playlist_videos(self, playlist_id):
|
def _get_playlist_videos(self, playlist_id: str) -> list[str]:
|
||||||
"""get all videos from playlist"""
|
"""get all videos from playlist"""
|
||||||
data = {
|
data = {
|
||||||
"query": {"term": {"playlist.keyword": {"value": playlist_id}}},
|
"query": {"term": {"playlist.keyword": {"value": playlist_id}}},
|
||||||
@@ -229,14 +257,13 @@ class Reindex(ReindexBase):
|
|||||||
def __init__(self, task=False):
|
def __init__(self, task=False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.task = task
|
self.task = task
|
||||||
self.all_indexed_ids = False
|
|
||||||
self.processed = {
|
self.processed = {
|
||||||
"videos": 0,
|
"videos": 0,
|
||||||
"channels": 0,
|
"channels": 0,
|
||||||
"playlists": 0,
|
"playlists": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def reindex_all(self):
|
def reindex_all(self) -> None:
|
||||||
"""reindex all in queue"""
|
"""reindex all in queue"""
|
||||||
if not self.cookie_is_valid():
|
if not self.cookie_is_valid():
|
||||||
print("[reindex] cookie invalid, exiting...")
|
print("[reindex] cookie invalid, exiting...")
|
||||||
@@ -246,26 +273,25 @@ class Reindex(ReindexBase):
|
|||||||
if not RedisQueue(index_config["queue_name"]).length():
|
if not RedisQueue(index_config["queue_name"]).length():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.total = RedisQueue(index_config["queue_name"]).length()
|
self.reindex_type(name, index_config)
|
||||||
while True:
|
|
||||||
has_next = self.reindex_index(name, index_config)
|
|
||||||
if not has_next:
|
|
||||||
break
|
|
||||||
|
|
||||||
def reindex_index(self, name, index_config):
|
def reindex_type(self, name: str, index_config: ReindexConfigType) -> None:
|
||||||
"""reindex all of a single index"""
|
"""reindex all of a single index"""
|
||||||
reindex = self.get_reindex_map(index_config["index_name"])
|
reindex = self._get_reindex_map(index_config["index_name"])
|
||||||
youtube_id = RedisQueue(index_config["queue_name"]).get_next()
|
queue = RedisQueue(index_config["queue_name"])
|
||||||
if youtube_id:
|
while True:
|
||||||
|
total = queue.max_score()
|
||||||
|
youtube_id, idx = queue.get_next()
|
||||||
|
if not youtube_id or not idx or not total:
|
||||||
|
break
|
||||||
|
|
||||||
if self.task:
|
if self.task:
|
||||||
self._notify(name, index_config)
|
self._notify(name, total, idx)
|
||||||
|
|
||||||
reindex(youtube_id)
|
reindex(youtube_id)
|
||||||
sleep_interval = self.config["downloads"].get("sleep_interval", 0)
|
rand_sleep(self.config)
|
||||||
sleep(sleep_interval)
|
|
||||||
|
|
||||||
return bool(youtube_id)
|
def _get_reindex_map(self, index_name: str) -> Callable:
|
||||||
|
|
||||||
def get_reindex_map(self, index_name):
|
|
||||||
"""return def to run for index"""
|
"""return def to run for index"""
|
||||||
def_map = {
|
def_map = {
|
||||||
"ta_video": self._reindex_single_video,
|
"ta_video": self._reindex_single_video,
|
||||||
@@ -273,20 +299,15 @@ class Reindex(ReindexBase):
|
|||||||
"ta_playlist": self._reindex_single_playlist,
|
"ta_playlist": self._reindex_single_playlist,
|
||||||
}
|
}
|
||||||
|
|
||||||
return def_map.get(index_name)
|
return def_map[index_name]
|
||||||
|
|
||||||
def _notify(self, name, index_config):
|
def _notify(self, name: str, total: int, idx: int) -> None:
|
||||||
"""send notification back to task"""
|
"""send notification back to task"""
|
||||||
if self.total is None:
|
message = [f"Reindexing {name.title()}s {idx}/{total}"]
|
||||||
self.total = RedisQueue(index_config["queue_name"]).length()
|
progress = idx / total
|
||||||
|
|
||||||
remaining = RedisQueue(index_config["queue_name"]).length()
|
|
||||||
idx = self.total - remaining
|
|
||||||
message = [f"Reindexing {name.title()}s {idx}/{self.total}"]
|
|
||||||
progress = idx / self.total
|
|
||||||
self.task.send_progress(message, progress=progress)
|
self.task.send_progress(message, progress=progress)
|
||||||
|
|
||||||
def _reindex_single_video(self, youtube_id):
|
def _reindex_single_video(self, youtube_id: str) -> None:
|
||||||
"""refresh data for single video"""
|
"""refresh data for single video"""
|
||||||
video = YoutubeVideo(youtube_id)
|
video = YoutubeVideo(youtube_id)
|
||||||
|
|
||||||
@@ -312,6 +333,7 @@ class Reindex(ReindexBase):
|
|||||||
# add back
|
# add back
|
||||||
video.json_data["player"] = es_meta.get("player")
|
video.json_data["player"] = es_meta.get("player")
|
||||||
video.json_data["date_downloaded"] = es_meta.get("date_downloaded")
|
video.json_data["date_downloaded"] = es_meta.get("date_downloaded")
|
||||||
|
video.json_data["vid_type"] = es_meta.get("vid_type")
|
||||||
video.json_data["channel"] = es_meta.get("channel")
|
video.json_data["channel"] = es_meta.get("channel")
|
||||||
if es_meta.get("playlist"):
|
if es_meta.get("playlist"):
|
||||||
video.json_data["playlist"] = es_meta.get("playlist")
|
video.json_data["playlist"] = es_meta.get("playlist")
|
||||||
@@ -325,9 +347,7 @@ class Reindex(ReindexBase):
|
|||||||
Comments(youtube_id, config=self.config).reindex_comments()
|
Comments(youtube_id, config=self.config).reindex_comments()
|
||||||
self.processed["videos"] += 1
|
self.processed["videos"] += 1
|
||||||
|
|
||||||
return
|
def _reindex_single_channel(self, channel_id: str) -> None:
|
||||||
|
|
||||||
def _reindex_single_channel(self, channel_id):
|
|
||||||
"""refresh channel data and sync to videos"""
|
"""refresh channel data and sync to videos"""
|
||||||
# read current state
|
# read current state
|
||||||
channel = YoutubeChannel(channel_id)
|
channel = YoutubeChannel(channel_id)
|
||||||
@@ -355,12 +375,12 @@ class Reindex(ReindexBase):
|
|||||||
channel.json_data["channel_overwrites"] = overwrites
|
channel.json_data["channel_overwrites"] = overwrites
|
||||||
|
|
||||||
channel.upload_to_es()
|
channel.upload_to_es()
|
||||||
|
channel.sync_to_videos()
|
||||||
ChannelFullScan(channel_id).scan()
|
ChannelFullScan(channel_id).scan()
|
||||||
self.processed["channels"] += 1
|
self.processed["channels"] += 1
|
||||||
|
|
||||||
def _reindex_single_playlist(self, playlist_id):
|
def _reindex_single_playlist(self, playlist_id: str) -> None:
|
||||||
"""refresh playlist data"""
|
"""refresh playlist data"""
|
||||||
self._get_all_videos()
|
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
playlist.get_from_es()
|
playlist.get_from_es()
|
||||||
if (
|
if (
|
||||||
@@ -369,29 +389,14 @@ class Reindex(ReindexBase):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
subscribed = playlist.json_data["playlist_subscribed"]
|
is_active = playlist.update_playlist()
|
||||||
playlist.all_youtube_ids = self.all_indexed_ids
|
if not is_active:
|
||||||
playlist.build_json(scrape=True)
|
|
||||||
if not playlist.json_data:
|
|
||||||
playlist.deactivate()
|
playlist.deactivate()
|
||||||
return
|
return
|
||||||
|
|
||||||
playlist.json_data["playlist_subscribed"] = subscribed
|
|
||||||
playlist.upload_to_es()
|
|
||||||
self.processed["playlists"] += 1
|
self.processed["playlists"] += 1
|
||||||
return
|
|
||||||
|
|
||||||
def _get_all_videos(self):
|
def cookie_is_valid(self) -> bool:
|
||||||
"""add all videos for playlist index validation"""
|
|
||||||
if self.all_indexed_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
handler = PendingList()
|
|
||||||
handler.get_download()
|
|
||||||
handler.get_indexed()
|
|
||||||
self.all_indexed_ids = [i["youtube_id"] for i in handler.all_videos]
|
|
||||||
|
|
||||||
def cookie_is_valid(self):
|
|
||||||
"""return true if cookie is enabled and valid"""
|
"""return true if cookie is enabled and valid"""
|
||||||
if not self.config["downloads"]["cookie_import"]:
|
if not self.config["downloads"]["cookie_import"]:
|
||||||
# is not activated, continue reindex
|
# is not activated, continue reindex
|
||||||
@@ -400,7 +405,7 @@ class Reindex(ReindexBase):
|
|||||||
valid = CookieHandler(self.config).validate()
|
valid = CookieHandler(self.config).validate()
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
def build_message(self):
|
def build_message(self) -> str:
|
||||||
"""build progress message"""
|
"""build progress message"""
|
||||||
message = ""
|
message = ""
|
||||||
for key, value in self.processed.items():
|
for key, value in self.processed.items():
|
||||||
@@ -430,7 +435,7 @@ class ReindexProgress(ReindexBase):
|
|||||||
self.request_type = request_type
|
self.request_type = request_type
|
||||||
self.request_id = request_id
|
self.request_id = request_id
|
||||||
|
|
||||||
def get_progress(self):
|
def get_progress(self) -> dict:
|
||||||
"""get progress from task"""
|
"""get progress from task"""
|
||||||
queue_name, request_type = self._get_queue_name()
|
queue_name, request_type = self._get_queue_name()
|
||||||
total = self._get_total_in_queue(queue_name)
|
total = self._get_total_in_queue(queue_name)
|
||||||
@@ -7,9 +7,9 @@ from datetime import datetime
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from home.src.es.connect import ElasticWrap
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.ta.helper import get_mapping
|
from common.src.es_connect import ElasticWrap
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from common.src.helper import get_mapping
|
||||||
|
|
||||||
|
|
||||||
class ElasticSnapshot:
|
class ElasticSnapshot:
|
||||||
@@ -150,7 +150,7 @@ class ElasticSnapshot:
|
|||||||
if statuscode == 200:
|
if statuscode == 200:
|
||||||
print(f"snapshot: executing now: {response}")
|
print(f"snapshot: executing now: {response}")
|
||||||
|
|
||||||
if wait:
|
if wait and "snapshot_name" in response:
|
||||||
self._wait_for_snapshot(response["snapshot_name"])
|
self._wait_for_snapshot(response["snapshot_name"])
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -251,10 +251,9 @@ class ElasticSnapshot:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _date_converter(date_utc):
|
def _date_converter(date_utc):
|
||||||
"""convert datetime string"""
|
"""convert datetime string"""
|
||||||
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ"
|
date = datetime.strptime(date_utc, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
date = datetime.strptime(date_utc, expected_format)
|
utc_date = date.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
local_datetime = date.replace(tzinfo=ZoneInfo("localtime"))
|
converted = utc_date.astimezone(ZoneInfo(EnvironmentSettings.TZ))
|
||||||
converted = local_datetime.astimezone(ZoneInfo(EnvironmentSettings.TZ))
|
|
||||||
converted_str = converted.strftime("%Y-%m-%d %H:%M")
|
converted_str = converted.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
return converted_str
|
return converted_str
|
||||||
47
backend/appsettings/urls.py
Normal file
47
backend/appsettings/urls.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""all app settings API urls"""
|
||||||
|
|
||||||
|
from appsettings import views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"config/",
|
||||||
|
views.AppConfigApiView.as_view(),
|
||||||
|
name="api-config",
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
"cookie/",
|
||||||
|
views.CookieView.as_view(),
|
||||||
|
name="api-cookie",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"potoken/",
|
||||||
|
views.POTokenView.as_view(),
|
||||||
|
name="api-potoken",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"token/",
|
||||||
|
views.TokenView.as_view(),
|
||||||
|
name="api-token",
|
||||||
|
),
|
||||||
|
]
|
||||||
493
backend/appsettings/views.py
Normal file
493
backend/appsettings/views.py
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
"""all app settings API views"""
|
||||||
|
|
||||||
|
from appsettings.serializers import (
|
||||||
|
AppConfigSerializer,
|
||||||
|
BackupFileSerializer,
|
||||||
|
CookieUpdateSerializer,
|
||||||
|
CookieValidationSerializer,
|
||||||
|
PoTokenSerializer,
|
||||||
|
SnapshotCreateResponseSerializer,
|
||||||
|
SnapshotItemSerializer,
|
||||||
|
SnapshotListSerializer,
|
||||||
|
SnapshotRestoreResponseSerializer,
|
||||||
|
TokenResponseSerializer,
|
||||||
|
)
|
||||||
|
from appsettings.src.backup import ElasticBackup
|
||||||
|
from appsettings.src.config import AppConfig
|
||||||
|
from appsettings.src.snapshot import ElasticSnapshot
|
||||||
|
from common.serializers import (
|
||||||
|
AsyncTaskResponseSerializer,
|
||||||
|
ErrorResponseSerializer,
|
||||||
|
)
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView
|
||||||
|
from django.conf import settings
|
||||||
|
from download.src.yt_dlp_base import CookieHandler, POTokenHandler
|
||||||
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from task.src.task_manager import TaskCommand
|
||||||
|
from task.tasks import run_restore_backup
|
||||||
|
|
||||||
|
|
||||||
|
class BackupApiListView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/backup/
|
||||||
|
GET: returns list of available zip backups
|
||||||
|
POST: take zip backup now
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
task_name = "run_backup"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(BackupFileSerializer(many=True)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get list of available backup files"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
backup_files = ElasticBackup().get_all_backup_files()
|
||||||
|
serializer = BackupFileSerializer(backup_files, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(AsyncTaskResponseSerializer()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""start new backup file task"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
response = TaskCommand().start(self.task_name)
|
||||||
|
message = {
|
||||||
|
"message": "backup task started",
|
||||||
|
"task_id": response["task_id"],
|
||||||
|
}
|
||||||
|
serializer = AsyncTaskResponseSerializer(message)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/backup/<filename>/
|
||||||
|
GET: return a single backup
|
||||||
|
POST: restore backup
|
||||||
|
DELETE: delete backup
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
task_name = "restore_backup"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(BackupFileSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="file not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request, filename):
|
||||||
|
"""get single backup"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
backup_file = ElasticBackup().build_backup_file_data(filename)
|
||||||
|
if not backup_file:
|
||||||
|
error = ErrorResponseSerializer({"error": "file not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
serializer = BackupFileSerializer(backup_file)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(AsyncTaskResponseSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="file not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request, filename):
|
||||||
|
"""start new task to restore backup file"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
backup_file = ElasticBackup().build_backup_file_data(filename)
|
||||||
|
if not backup_file:
|
||||||
|
error = ErrorResponseSerializer({"error": "file not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
task = run_restore_backup.delay(filename)
|
||||||
|
message = {
|
||||||
|
"message": "backup restore task started",
|
||||||
|
"filename": filename,
|
||||||
|
"task_id": task.id,
|
||||||
|
}
|
||||||
|
return Response(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="file deleted"),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="file not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(request, filename):
|
||||||
|
"""delete backup file"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
|
||||||
|
backup_file = ElasticBackup().delete_file(filename)
|
||||||
|
if not backup_file:
|
||||||
|
error = ErrorResponseSerializer({"error": "file not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/config/
|
||||||
|
GET: return app settings
|
||||||
|
POST: update app settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminWriteOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(AppConfigSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get app config"""
|
||||||
|
response = AppConfig().config
|
||||||
|
serializer = AppConfigSerializer(response)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
request=AppConfigSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(AppConfigSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(request):
|
||||||
|
"""update config values, allows partial update"""
|
||||||
|
serializer = AppConfigSerializer(data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
updated_config = AppConfig().update_config(validated_data)
|
||||||
|
updated_serializer = AppConfigSerializer(updated_config)
|
||||||
|
return Response(updated_serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/cookie/
|
||||||
|
GET: check if cookie is enabled
|
||||||
|
POST: verify validity of cookie
|
||||||
|
PUT: import cookie
|
||||||
|
DELETE: revoke the cookie
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(CookieValidationSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get cookie validation status"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
validation = self._get_cookie_validation()
|
||||||
|
serializer = CookieValidationSerializer(validation)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(CookieValidationSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""validate cookie"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
config = AppConfig().config
|
||||||
|
_ = CookieHandler(config).validate()
|
||||||
|
validation = self._get_cookie_validation()
|
||||||
|
serializer = CookieValidationSerializer(validation)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=CookieUpdateSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(CookieValidationSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def put(self, request):
|
||||||
|
"""handle put request"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
|
||||||
|
serializer = CookieUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
cookie = validated_data.get("cookie")
|
||||||
|
if not cookie:
|
||||||
|
message = "missing cookie key in request data"
|
||||||
|
print(message)
|
||||||
|
error = ErrorResponseSerializer({"error": message})
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
print(f"[cookie] preview:\n\n{cookie[:300]}")
|
||||||
|
|
||||||
|
config = AppConfig().config
|
||||||
|
handler = CookieHandler(config)
|
||||||
|
handler.set_cookie(cookie)
|
||||||
|
validated = handler.validate()
|
||||||
|
if not validated:
|
||||||
|
message = "[cookie]: import failed, not valid"
|
||||||
|
print(message)
|
||||||
|
error = ErrorResponseSerializer({"error": message})
|
||||||
|
handler.revoke()
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
validation = self._get_cookie_validation()
|
||||||
|
serializer = CookieValidationSerializer(validation)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="Cookie revoked"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete(self, request):
|
||||||
|
"""delete the cookie"""
|
||||||
|
config = AppConfig().config
|
||||||
|
handler = CookieHandler(config)
|
||||||
|
handler.revoke()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_cookie_validation():
|
||||||
|
"""get current cookie validation"""
|
||||||
|
config = AppConfig().config
|
||||||
|
validation = RedisArchivist().get_message_dict("cookie:valid")
|
||||||
|
is_enabled = {"cookie_enabled": config["downloads"]["cookie_import"]}
|
||||||
|
validation.update(is_enabled)
|
||||||
|
|
||||||
|
return validation
|
||||||
|
|
||||||
|
|
||||||
|
class POTokenView(ApiBaseView):
|
||||||
|
"""handle PO token"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(PoTokenSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="PO token not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get PO token"""
|
||||||
|
config = AppConfig().config
|
||||||
|
potoken = POTokenHandler(config).get()
|
||||||
|
if not potoken:
|
||||||
|
error = ErrorResponseSerializer({"error": "PO token not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
serializer = PoTokenSerializer(data={"potoken": potoken})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(PoTokenSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""Update PO token"""
|
||||||
|
serializer = PoTokenSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
if not validated_data:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "missing PO token key in request data"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
config = AppConfig().config
|
||||||
|
new_token = validated_data["potoken"]
|
||||||
|
|
||||||
|
POTokenHandler(config).set_token(new_token)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="PO token revoked"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete(self, request):
|
||||||
|
"""delete PO token"""
|
||||||
|
config = AppConfig().config
|
||||||
|
POTokenHandler(config).revoke_token()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotApiListView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/snapshot/
|
||||||
|
GET: returns snapshot config plus list of existing snapshots
|
||||||
|
POST: take snapshot now
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(SnapshotListSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get available snapshots with metadata"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
snapshots = ElasticSnapshot().get_snapshot_stats()
|
||||||
|
serializer = SnapshotListSerializer(snapshots)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(SnapshotCreateResponseSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(request):
|
||||||
|
"""take snapshot now"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
response = ElasticSnapshot().take_snapshot_now()
|
||||||
|
serializer = SnapshotCreateResponseSerializer(response)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/snapshot/<snapshot-id>/
|
||||||
|
GET: return a single snapshot
|
||||||
|
POST: restore snapshot
|
||||||
|
DELETE: delete a snapshot
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(SnapshotItemSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="snapshot not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request, snapshot_id):
|
||||||
|
"""handle get request"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id)
|
||||||
|
|
||||||
|
if not snapshot:
|
||||||
|
error = ErrorResponseSerializer({"error": "snapshot not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
serializer = SnapshotItemSerializer(snapshot)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(SnapshotRestoreResponseSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="bad request"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(request, snapshot_id):
|
||||||
|
"""restore snapshot"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
response = ElasticSnapshot().restore_all(snapshot_id)
|
||||||
|
if not response:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "failed to restore snapshot"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
serializer = SnapshotRestoreResponseSerializer(response)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="delete snapshot from index"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(request, snapshot_id):
|
||||||
|
"""delete snapshot from index"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
response = ElasticSnapshot().delete_single_snapshot(snapshot_id)
|
||||||
|
if not response:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "failed to delete snapshot"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/token/
|
||||||
|
GET: get API token
|
||||||
|
DELETE: revoke the token
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(TokenResponseSerializer()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get your API token"""
|
||||||
|
token, _ = Token.objects.get_or_create(user=request.user)
|
||||||
|
serializer = TokenResponseSerializer({"token": token.key})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="delete token"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(request):
|
||||||
|
"""delete your API token, new will get created on next get"""
|
||||||
|
print("revoke API token")
|
||||||
|
request.user.auth_token.delete()
|
||||||
|
return Response(status=204)
|
||||||
103
backend/channel/serializers.py
Normal file
103
backend/channel/serializers.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""channel serializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelOverwriteSerializer(
|
||||||
|
ValidateUnknownFieldsMixin, serializers.Serializer
|
||||||
|
):
|
||||||
|
"""serialize channel overwrites"""
|
||||||
|
|
||||||
|
download_format = serializers.CharField(required=False, allow_null=True)
|
||||||
|
autodelete_days = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
index_playlists = serializers.BooleanField(required=False, allow_null=True)
|
||||||
|
integrate_sponsorblock = serializers.BooleanField(
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
subscriptions_channel_size = serializers.IntegerField(
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
subscriptions_live_channel_size = serializers.IntegerField(
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
subscriptions_shorts_channel_size = serializers.IntegerField(
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSerializer(serializers.Serializer):
|
||||||
|
"""serialize channel"""
|
||||||
|
|
||||||
|
channel_id = serializers.CharField()
|
||||||
|
channel_active = serializers.BooleanField()
|
||||||
|
channel_banner_url = serializers.CharField()
|
||||||
|
channel_thumb_url = serializers.CharField()
|
||||||
|
channel_tvart_url = serializers.CharField()
|
||||||
|
channel_description = serializers.CharField()
|
||||||
|
channel_last_refresh = serializers.CharField()
|
||||||
|
channel_name = serializers.CharField()
|
||||||
|
channel_overwrites = ChannelOverwriteSerializer(required=False)
|
||||||
|
channel_subs = serializers.IntegerField()
|
||||||
|
channel_subscribed = serializers.BooleanField()
|
||||||
|
channel_tags = serializers.ListField(
|
||||||
|
child=serializers.CharField(), required=False
|
||||||
|
)
|
||||||
|
channel_views = serializers.IntegerField()
|
||||||
|
_index = serializers.CharField(required=False)
|
||||||
|
_score = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelListSerializer(serializers.Serializer):
|
||||||
|
"""serialize channel list"""
|
||||||
|
|
||||||
|
data = ChannelSerializer(many=True)
|
||||||
|
paginate = PaginationSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelListQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize list query"""
|
||||||
|
|
||||||
|
filter = serializers.ChoiceField(choices=["subscribed"], required=False)
|
||||||
|
page = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelUpdateSerializer(serializers.Serializer):
|
||||||
|
"""update channel"""
|
||||||
|
|
||||||
|
channel_subscribed = serializers.BooleanField(required=False)
|
||||||
|
channel_overwrites = ChannelOverwriteSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAggBucketSerializer(serializers.Serializer):
|
||||||
|
"""serialize channel agg bucket"""
|
||||||
|
|
||||||
|
value = serializers.IntegerField()
|
||||||
|
value_str = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAggSerializer(serializers.Serializer):
|
||||||
|
"""serialize channel aggregation"""
|
||||||
|
|
||||||
|
total_items = ChannelAggBucketSerializer()
|
||||||
|
total_size = ChannelAggBucketSerializer()
|
||||||
|
total_duration = ChannelAggBucketSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelNavSerializer(serializers.Serializer):
|
||||||
|
"""serialize channel navigation"""
|
||||||
|
|
||||||
|
has_pending = serializers.BooleanField()
|
||||||
|
has_ignored = serializers.BooleanField()
|
||||||
|
has_playlists = serializers.BooleanField()
|
||||||
|
has_videos = serializers.BooleanField()
|
||||||
|
has_streams = serializers.BooleanField()
|
||||||
|
has_shorts = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSearchQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize query parameters for searching"""
|
||||||
|
|
||||||
|
q = serializers.CharField()
|
||||||
@@ -8,13 +8,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.download import queue # partial import
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from common.src.helper import rand_sleep
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.index_generic import YouTubeItem
|
||||||
from home.src.index.generic import YouTubeItem
|
from download.src.thumbnails import ThumbManager
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
|
||||||
|
|
||||||
|
|
||||||
class YoutubeChannel(YouTubeItem):
|
class YoutubeChannel(YouTubeItem):
|
||||||
@@ -23,17 +22,16 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
es_path = False
|
es_path = False
|
||||||
index_name = "ta_channel"
|
index_name = "ta_channel"
|
||||||
yt_base = "https://www.youtube.com/channel/"
|
yt_base = "https://www.youtube.com/channel/"
|
||||||
yt_obs = {"playlist_items": "0,0"}
|
yt_obs = {
|
||||||
|
"playlist_items": "0,0",
|
||||||
|
"skip_download": True,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, youtube_id, task=False):
|
def __init__(self, youtube_id, task=False):
|
||||||
super().__init__(youtube_id)
|
super().__init__(youtube_id)
|
||||||
self.all_playlists = False
|
self.all_playlists = False
|
||||||
self.task = task
|
self.task = task
|
||||||
|
|
||||||
def build_yt_url(self):
|
|
||||||
"""overwrite base to use channel about page"""
|
|
||||||
return f"{self.yt_base}{self.youtube_id}/about"
|
|
||||||
|
|
||||||
def build_json(self, upload=False, fallback=False):
|
def build_json(self, upload=False, fallback=False):
|
||||||
"""get from es or from youtube"""
|
"""get from es or from youtube"""
|
||||||
self.get_from_es()
|
self.get_from_es()
|
||||||
@@ -44,6 +42,10 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
if not self.youtube_meta and fallback:
|
if not self.youtube_meta and fallback:
|
||||||
self._video_fallback(fallback)
|
self._video_fallback(fallback)
|
||||||
else:
|
else:
|
||||||
|
if not self.youtube_meta:
|
||||||
|
message = f"{self.youtube_id}: Failed to get metadata"
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
self.process_youtube_meta()
|
self.process_youtube_meta()
|
||||||
self.get_channel_art()
|
self.get_channel_art()
|
||||||
|
|
||||||
@@ -53,30 +55,22 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
def process_youtube_meta(self):
|
def process_youtube_meta(self):
|
||||||
"""extract relevant fields"""
|
"""extract relevant fields"""
|
||||||
self.youtube_meta["thumbnails"].reverse()
|
self.youtube_meta["thumbnails"].reverse()
|
||||||
channel_subs = self.youtube_meta.get("channel_follower_count") or 0
|
channel_name = self.youtube_meta["uploader"] or self.youtube_meta["id"]
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"channel_active": True,
|
"channel_active": True,
|
||||||
"channel_description": self.youtube_meta.get("description", False),
|
"channel_description": self.youtube_meta.get("description", ""),
|
||||||
"channel_id": self.youtube_id,
|
"channel_id": self.youtube_id,
|
||||||
"channel_last_refresh": int(datetime.now().timestamp()),
|
"channel_last_refresh": int(datetime.now().timestamp()),
|
||||||
"channel_name": self.youtube_meta["uploader"],
|
"channel_name": channel_name,
|
||||||
"channel_subs": channel_subs,
|
"channel_subs": self.youtube_meta.get("channel_follower_count", 0),
|
||||||
"channel_subscribed": False,
|
"channel_subscribed": False,
|
||||||
"channel_tags": self._parse_tags(self.youtube_meta.get("tags")),
|
"channel_tags": self.youtube_meta.get("tags", []),
|
||||||
"channel_banner_url": self._get_banner_art(),
|
"channel_banner_url": self._get_banner_art(),
|
||||||
"channel_thumb_url": self._get_thumb_art(),
|
"channel_thumb_url": self._get_thumb_art(),
|
||||||
"channel_tvart_url": self._get_tv_art(),
|
"channel_tvart_url": self._get_tv_art(),
|
||||||
"channel_views": self.youtube_meta.get("view_count") or 0,
|
"channel_views": self.youtube_meta.get("view_count") or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_tags(self, tags):
|
|
||||||
"""parse channel tags"""
|
|
||||||
if not tags:
|
|
||||||
return False
|
|
||||||
|
|
||||||
joined = " ".join(tags)
|
|
||||||
return [i.strip() for i in joined.split('"') if i and not i == " "]
|
|
||||||
|
|
||||||
def _get_thumb_art(self):
|
def _get_thumb_art(self):
|
||||||
"""extract thumb art"""
|
"""extract thumb art"""
|
||||||
for i in self.youtube_meta["thumbnails"]:
|
for i in self.youtube_meta["thumbnails"]:
|
||||||
@@ -122,8 +116,8 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
"channel_tvart_url": False,
|
"channel_tvart_url": False,
|
||||||
"channel_id": self.youtube_id,
|
"channel_id": self.youtube_id,
|
||||||
"channel_subscribed": False,
|
"channel_subscribed": False,
|
||||||
"channel_tags": False,
|
"channel_tags": [],
|
||||||
"channel_description": False,
|
"channel_description": "",
|
||||||
"channel_thumb_url": False,
|
"channel_thumb_url": False,
|
||||||
"channel_views": 0,
|
"channel_views": 0,
|
||||||
}
|
}
|
||||||
@@ -163,8 +157,18 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
# add ingest pipeline
|
# add ingest pipeline
|
||||||
processors = []
|
processors = []
|
||||||
for field, value in self.json_data.items():
|
for field, value in self.json_data.items():
|
||||||
line = {"set": {"field": "channel." + field, "value": value}}
|
if value is None:
|
||||||
|
line = {
|
||||||
|
"script": {
|
||||||
|
"lang": "painless",
|
||||||
|
"source": f"ctx['{field}'] = null;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
line = {"set": {"field": "channel." + field, "value": value}}
|
||||||
|
|
||||||
processors.append(line)
|
processors.append(line)
|
||||||
|
|
||||||
data = {"description": self.youtube_id, "processors": processors}
|
data = {"description": self.youtube_id, "processors": processors}
|
||||||
ingest_path = f"_ingest/pipeline/{self.youtube_id}"
|
ingest_path = f"_ingest/pipeline/{self.youtube_id}"
|
||||||
_, _ = ElasticWrap(ingest_path).put(data)
|
_, _ = ElasticWrap(ingest_path).put(data)
|
||||||
@@ -210,11 +214,11 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
|
|
||||||
def delete_playlists(self):
|
def delete_playlists(self):
|
||||||
"""delete all indexed playlist from es"""
|
"""delete all indexed playlist from es"""
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
|
||||||
all_playlists = self.get_indexed_playlists()
|
all_playlists = self.get_indexed_playlists()
|
||||||
for playlist in all_playlists:
|
for playlist in all_playlists:
|
||||||
playlist_id = playlist["playlist_id"]
|
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
|
||||||
YoutubePlaylist(playlist_id).delete_metadata()
|
|
||||||
|
|
||||||
def delete_channel(self):
|
def delete_channel(self):
|
||||||
"""delete channel and all videos"""
|
"""delete channel and all videos"""
|
||||||
@@ -253,14 +257,14 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
print(f"{self.youtube_id}: no playlists found.")
|
print(f"{self.youtube_id}: no playlists found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
all_youtube_ids = self.get_all_video_ids()
|
|
||||||
total = len(self.all_playlists)
|
total = len(self.all_playlists)
|
||||||
for idx, playlist in enumerate(self.all_playlists):
|
for idx, playlist in enumerate(self.all_playlists):
|
||||||
if self.task:
|
if self.task:
|
||||||
self._notify_single_playlist(idx, total)
|
self._notify_single_playlist(idx, total)
|
||||||
|
|
||||||
self._index_single_playlist(playlist, all_youtube_ids)
|
self._index_single_playlist(playlist)
|
||||||
print("add playlist: " + playlist[1])
|
print("add playlist: " + playlist[1])
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
def _notify_single_playlist(self, idx, total):
|
def _notify_single_playlist(self, idx, total):
|
||||||
"""send notification"""
|
"""send notification"""
|
||||||
@@ -272,32 +276,12 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
self.task.send_progress(message, progress=(idx + 1) / total)
|
self.task.send_progress(message, progress=(idx + 1) / total)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _index_single_playlist(playlist, all_youtube_ids):
|
def _index_single_playlist(playlist):
|
||||||
"""add single playlist if needed"""
|
"""add single playlist if needed"""
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
|
||||||
playlist = YoutubePlaylist(playlist[0])
|
playlist = YoutubePlaylist(playlist[0])
|
||||||
playlist.all_youtube_ids = all_youtube_ids
|
playlist.update_playlist(skip_on_empty=True)
|
||||||
playlist.build_json()
|
|
||||||
if not playlist.json_data:
|
|
||||||
return
|
|
||||||
|
|
||||||
entries = playlist.json_data["playlist_entries"]
|
|
||||||
downloaded = [i for i in entries if i["downloaded"]]
|
|
||||||
if not downloaded:
|
|
||||||
return
|
|
||||||
|
|
||||||
playlist.upload_to_es()
|
|
||||||
playlist.add_vids_to_playlist()
|
|
||||||
playlist.get_playlist_art()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_all_video_ids():
|
|
||||||
"""match all playlists with videos"""
|
|
||||||
handler = queue.PendingList()
|
|
||||||
handler.get_download()
|
|
||||||
handler.get_indexed()
|
|
||||||
all_youtube_ids = [i["youtube_id"] for i in handler.all_videos]
|
|
||||||
|
|
||||||
return all_youtube_ids
|
|
||||||
|
|
||||||
def get_channel_videos(self):
|
def get_channel_videos(self):
|
||||||
"""get all videos from channel"""
|
"""get all videos from channel"""
|
||||||
@@ -318,6 +302,10 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
)
|
)
|
||||||
obs = {"skip_download": True, "extract_flat": True}
|
obs = {"skip_download": True, "extract_flat": True}
|
||||||
playlists = YtWrap(obs, self.config).extract(url)
|
playlists = YtWrap(obs, self.config).extract(url)
|
||||||
|
if not playlists:
|
||||||
|
self.all_playlists = []
|
||||||
|
return
|
||||||
|
|
||||||
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
||||||
self.all_playlists = all_entries
|
self.all_playlists = all_entries
|
||||||
|
|
||||||
@@ -334,9 +322,9 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||||
return all_playlists
|
return all_playlists
|
||||||
|
|
||||||
def get_overwrites(self):
|
def get_overwrites(self) -> dict:
|
||||||
"""get all per channel overwrites"""
|
"""get all per channel overwrites"""
|
||||||
return self.json_data.get("channel_overwrites", False)
|
return self.json_data.get("channel_overwrites", {})
|
||||||
|
|
||||||
def set_overwrites(self, overwrites):
|
def set_overwrites(self, overwrites):
|
||||||
"""set per channel overwrites"""
|
"""set per channel overwrites"""
|
||||||
@@ -345,24 +333,21 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
"autodelete_days",
|
"autodelete_days",
|
||||||
"index_playlists",
|
"index_playlists",
|
||||||
"integrate_sponsorblock",
|
"integrate_sponsorblock",
|
||||||
|
"subscriptions_channel_size",
|
||||||
|
"subscriptions_live_channel_size",
|
||||||
|
"subscriptions_shorts_channel_size",
|
||||||
]
|
]
|
||||||
|
|
||||||
to_write = self.json_data.get("channel_overwrites", {})
|
to_write = self.json_data.get("channel_overwrites", {})
|
||||||
for key, value in overwrites.items():
|
for key, value in overwrites.items():
|
||||||
if key not in valid_keys:
|
if key not in valid_keys:
|
||||||
raise ValueError(f"invalid overwrite key: {key}")
|
raise ValueError(f"invalid overwrite key: {key}")
|
||||||
if value == "disable":
|
|
||||||
to_write[key] = False
|
if value is None and key in to_write:
|
||||||
|
to_write.pop(key)
|
||||||
continue
|
continue
|
||||||
if value in [0, "0"]:
|
|
||||||
if key in to_write:
|
to_write.update({key: value})
|
||||||
del to_write[key]
|
|
||||||
continue
|
|
||||||
if value == "1":
|
|
||||||
to_write[key] = True
|
|
||||||
continue
|
|
||||||
if value:
|
|
||||||
to_write.update({key: value})
|
|
||||||
|
|
||||||
self.json_data["channel_overwrites"] = to_write
|
self.json_data["channel_overwrites"] = to_write
|
||||||
|
|
||||||
@@ -374,3 +359,5 @@ def channel_overwrites(channel_id, overwrites):
|
|||||||
channel.set_overwrites(overwrites)
|
channel.set_overwrites(overwrites)
|
||||||
channel.upload_to_es()
|
channel.upload_to_es()
|
||||||
channel.sync_to_videos()
|
channel.sync_to_videos()
|
||||||
|
|
||||||
|
return channel.json_data
|
||||||
97
backend/channel/src/nav.py
Normal file
97
backend/channel/src/nav.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""build channel nav"""
|
||||||
|
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelNav:
|
||||||
|
"""get all nav items"""
|
||||||
|
|
||||||
|
def __init__(self, channel_id):
|
||||||
|
self.channel_id = channel_id
|
||||||
|
|
||||||
|
def get_nav(self):
|
||||||
|
"""build nav items"""
|
||||||
|
nav = {
|
||||||
|
"has_pending": self._get_has_pending(),
|
||||||
|
"has_ignored": self._get_has_ignored(),
|
||||||
|
"has_playlists": self._get_has_playlists(),
|
||||||
|
}
|
||||||
|
nav.update(self._get_vid_types())
|
||||||
|
return nav
|
||||||
|
|
||||||
|
def _get_vid_types(self):
|
||||||
|
"""get available vid_types in given channel"""
|
||||||
|
data = {
|
||||||
|
"size": 0,
|
||||||
|
"query": {
|
||||||
|
"term": {"channel.channel_id": {"value": self.channel_id}}
|
||||||
|
},
|
||||||
|
"aggs": {"unique_values": {"terms": {"field": "vid_type"}}},
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_video/_search").get(data)
|
||||||
|
buckets = response["aggregations"]["unique_values"]["buckets"]
|
||||||
|
|
||||||
|
type_nav = {
|
||||||
|
"has_videos": False,
|
||||||
|
"has_streams": False,
|
||||||
|
"has_shorts": False,
|
||||||
|
}
|
||||||
|
for bucket in buckets:
|
||||||
|
if bucket["key"] == "videos":
|
||||||
|
type_nav["has_videos"] = True
|
||||||
|
if bucket["key"] == "streams":
|
||||||
|
type_nav["has_streams"] = True
|
||||||
|
if bucket["key"] == "shorts":
|
||||||
|
type_nav["has_shorts"] = True
|
||||||
|
|
||||||
|
return type_nav
|
||||||
|
|
||||||
|
def _get_has_pending(self):
|
||||||
|
"""check if has pending videos in download queue"""
|
||||||
|
data = {
|
||||||
|
"size": 1,
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{"term": {"status": {"value": "pending"}}},
|
||||||
|
{"term": {"channel_id": {"value": self.channel_id}}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_source": False,
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_download/_search").get(data=data)
|
||||||
|
|
||||||
|
return bool(response["hits"]["hits"])
|
||||||
|
|
||||||
|
def _get_has_ignored(self):
|
||||||
|
"""Check if there are ignored videos in the download queue"""
|
||||||
|
data = {
|
||||||
|
"size": 1,
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{"term": {"status": {"value": "ignore"}}},
|
||||||
|
{"term": {"channel_id": {"value": self.channel_id}}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_source": False,
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_download/_search").get(data=data)
|
||||||
|
|
||||||
|
return bool(response["hits"]["hits"])
|
||||||
|
|
||||||
|
def _get_has_playlists(self):
|
||||||
|
"""check if channel has playlists"""
|
||||||
|
path = "ta_playlist/_search"
|
||||||
|
data = {
|
||||||
|
"size": 1,
|
||||||
|
"query": {
|
||||||
|
"term": {"playlist_channel_id": {"value": self.channel_id}}
|
||||||
|
},
|
||||||
|
"_source": False,
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap(path).get(data=data)
|
||||||
|
|
||||||
|
return bool(response["hits"]["hits"])
|
||||||
32
backend/channel/urls.py
Normal file
32
backend/channel/urls.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""all channel API urls"""
|
||||||
|
|
||||||
|
from channel import views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"",
|
||||||
|
views.ChannelApiListView.as_view(),
|
||||||
|
name="api-channel-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"search/",
|
||||||
|
views.ChannelApiSearchView.as_view(),
|
||||||
|
name="api-channel-search",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:channel_id>/",
|
||||||
|
views.ChannelApiView.as_view(),
|
||||||
|
name="api-channel",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:channel_id>/aggs/",
|
||||||
|
views.ChannelAggsApiView.as_view(),
|
||||||
|
name="api-channel-aggs",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:channel_id>/nav/",
|
||||||
|
views.ChannelNavApiView.as_view(),
|
||||||
|
name="api-channel-nav",
|
||||||
|
),
|
||||||
|
]
|
||||||
281
backend/channel/views.py
Normal file
281
backend/channel/views.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""all channel API views"""
|
||||||
|
|
||||||
|
from channel.serializers import (
|
||||||
|
ChannelAggSerializer,
|
||||||
|
ChannelListQuerySerializer,
|
||||||
|
ChannelListSerializer,
|
||||||
|
ChannelNavSerializer,
|
||||||
|
ChannelSearchQuerySerializer,
|
||||||
|
ChannelSerializer,
|
||||||
|
ChannelUpdateSerializer,
|
||||||
|
)
|
||||||
|
from channel.src.index import YoutubeChannel, channel_overwrites
|
||||||
|
from channel.src.nav import ChannelNav
|
||||||
|
from common.serializers import ErrorResponseSerializer
|
||||||
|
from common.src.urlparser import Parser
|
||||||
|
from common.views_base import AdminWriteOnly, ApiBaseView
|
||||||
|
from download.src.subscriptions import ChannelSubscription
|
||||||
|
from drf_spectacular.utils import (
|
||||||
|
OpenApiParameter,
|
||||||
|
OpenApiResponse,
|
||||||
|
extend_schema,
|
||||||
|
)
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from task.tasks import index_channel_playlists, subscribe_to
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelApiListView(ApiBaseView):
|
||||||
|
"""resolves to /api/channel/
|
||||||
|
GET: returns list of channels
|
||||||
|
POST: edit a list of channels
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_channel/_search/"
|
||||||
|
valid_filter = ["subscribed"]
|
||||||
|
permission_classes = [AdminWriteOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelListSerializer()),
|
||||||
|
},
|
||||||
|
parameters=[ChannelListQuerySerializer()],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get request"""
|
||||||
|
self.data.update(
|
||||||
|
{"sort": [{"channel_name.keyword": {"order": "asc"}}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ChannelListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
must_list = []
|
||||||
|
query_filter = validated_data.get("filter")
|
||||||
|
if query_filter:
|
||||||
|
must_list.append({"term": {"channel_subscribed": {"value": True}}})
|
||||||
|
|
||||||
|
self.data["query"] = {"bool": {"must": must_list}}
|
||||||
|
self.get_document_list(request)
|
||||||
|
serializer = ChannelListSerializer(self.response)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""subscribe/unsubscribe to list of channels"""
|
||||||
|
data = request.data
|
||||||
|
try:
|
||||||
|
to_add = data["data"]
|
||||||
|
except KeyError:
|
||||||
|
message = "missing expected data key"
|
||||||
|
print(message)
|
||||||
|
return Response({"message": message}, status=400)
|
||||||
|
|
||||||
|
pending = []
|
||||||
|
for channel_item in to_add:
|
||||||
|
channel_id = channel_item["channel_id"]
|
||||||
|
if channel_item["channel_subscribed"]:
|
||||||
|
pending.append(channel_id)
|
||||||
|
else:
|
||||||
|
self._unsubscribe(channel_id)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
url_str = " ".join(pending)
|
||||||
|
subscribe_to.delay(url_str, expected_type="channel")
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unsubscribe(channel_id: str):
|
||||||
|
"""unsubscribe"""
|
||||||
|
print(f"[{channel_id}] unsubscribe from channel")
|
||||||
|
ChannelSubscription().change_subscribe(
|
||||||
|
channel_id, channel_subscribed=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/channel/<channel_id>/
|
||||||
|
GET: returns metadata dict of channel
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_channel/_doc/"
|
||||||
|
permission_classes = [AdminWriteOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Channel not found"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, channel_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""get channel detail"""
|
||||||
|
self.get_document(channel_id)
|
||||||
|
if not self.response:
|
||||||
|
error = ErrorResponseSerializer({"error": "channel not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
response_serializer = ChannelSerializer(self.response)
|
||||||
|
return Response(response_serializer.data, status=self.status_code)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ChannelUpdateSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelUpdateSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Channel not found"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request, channel_id):
|
||||||
|
"""modify channel"""
|
||||||
|
self.get_document(channel_id)
|
||||||
|
if not self.response:
|
||||||
|
error = ErrorResponseSerializer({"error": "channel not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
serializer = ChannelUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
subscribed = validated_data.get("channel_subscribed")
|
||||||
|
if subscribed is not None:
|
||||||
|
ChannelSubscription().change_subscribe(channel_id, subscribed)
|
||||||
|
|
||||||
|
overwrites = validated_data.get("channel_overwrites")
|
||||||
|
if overwrites:
|
||||||
|
channel_overwrites(channel_id, overwrites)
|
||||||
|
if overwrites.get("index_playlists"):
|
||||||
|
index_channel_playlists.delay(channel_id)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=200)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="Channel deleted"),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Channel not found"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete(self, request, channel_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""delete channel"""
|
||||||
|
try:
|
||||||
|
YoutubeChannel(channel_id).delete_channel()
|
||||||
|
return Response(status=204)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
error = ErrorResponseSerializer({"error": "channel not found"})
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAggsApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/channel/<channel_id>/aggs/
|
||||||
|
GET: get channel aggregations
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_video/_search"
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelAggSerializer()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(self, request, channel_id):
|
||||||
|
"""get channel aggregations"""
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"query": {
|
||||||
|
"term": {"channel.channel_id": {"value": channel_id}}
|
||||||
|
},
|
||||||
|
"aggs": {
|
||||||
|
"total_items": {"value_count": {"field": "youtube_id"}},
|
||||||
|
"total_size": {"sum": {"field": "media_size"}},
|
||||||
|
"total_duration": {"sum": {"field": "player.duration"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.get_aggs()
|
||||||
|
serializer = ChannelAggSerializer(self.response)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelNavApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/channel/<channel_id>/nav/
|
||||||
|
GET: get channel nav
|
||||||
|
"""
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelNavSerializer()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(self, request, channel_id):
|
||||||
|
"""get navigation"""
|
||||||
|
|
||||||
|
nav = ChannelNav(channel_id).get_nav()
|
||||||
|
serializer = ChannelNavSerializer(nav)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelApiSearchView(ApiBaseView):
|
||||||
|
"""resolves to /api/channel/search/
|
||||||
|
search for channel
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_channel/_doc/"
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(ChannelSerializer()),
|
||||||
|
400: OpenApiResponse(description="Bad Request"),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Channel not found"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="q",
|
||||||
|
description="Search query string",
|
||||||
|
required=True,
|
||||||
|
type=str,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""search for local channel ID"""
|
||||||
|
|
||||||
|
serializer = ChannelSearchQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
query = validated_data.get("q")
|
||||||
|
if not query:
|
||||||
|
message = "missing expected q parameter"
|
||||||
|
return Response({"message": message, "data": False}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = Parser(query).parse()[0]
|
||||||
|
except (ValueError, IndexError, AttributeError):
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": f"channel not found: {query}"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
if not parsed["type"] == "channel":
|
||||||
|
error = ErrorResponseSerializer({"error": "expected channel data"})
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
self.get_document(parsed["url"])
|
||||||
|
serializer = ChannelSerializer(self.response)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=self.status_code)
|
||||||
143
backend/common/serializers.py
Normal file
143
backend/common/serializers.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""common serializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateUnknownFieldsMixin:
|
||||||
|
"""
|
||||||
|
Mixin to validate and reject unknown fields in a serializer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
"""check expected keys"""
|
||||||
|
allowed_fields = set(self.fields.keys())
|
||||||
|
input_fields = set(data.keys())
|
||||||
|
|
||||||
|
# Find unknown fields
|
||||||
|
unknown_fields = input_fields - allowed_fields
|
||||||
|
if unknown_fields:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": f"Unknown fields: {', '.join(unknown_fields)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponseSerializer(serializers.Serializer):
|
||||||
|
"""error message"""
|
||||||
|
|
||||||
|
error = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationSerializer(serializers.Serializer):
|
||||||
|
"""serialize paginate response"""
|
||||||
|
|
||||||
|
page_size = serializers.IntegerField()
|
||||||
|
page_from = serializers.IntegerField()
|
||||||
|
prev_pages = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(), allow_null=True
|
||||||
|
)
|
||||||
|
current_page = serializers.IntegerField()
|
||||||
|
max_hits = serializers.BooleanField()
|
||||||
|
params = serializers.CharField()
|
||||||
|
last_page = serializers.IntegerField()
|
||||||
|
next_pages = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(), allow_null=True
|
||||||
|
)
|
||||||
|
total_hits = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTaskResponseSerializer(serializers.Serializer):
|
||||||
|
"""serialize new async task"""
|
||||||
|
|
||||||
|
message = serializers.CharField(required=False)
|
||||||
|
task_id = serializers.CharField()
|
||||||
|
status = serializers.CharField(required=False)
|
||||||
|
filename = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSerializer(serializers.Serializer):
|
||||||
|
"""serialize notification messages"""
|
||||||
|
|
||||||
|
id = serializers.CharField()
|
||||||
|
title = serializers.CharField()
|
||||||
|
group = serializers.CharField()
|
||||||
|
api_start = serializers.BooleanField()
|
||||||
|
api_stop = serializers.BooleanField()
|
||||||
|
level = serializers.ChoiceField(choices=["info", "error"])
|
||||||
|
messages = serializers.ListField(child=serializers.CharField())
|
||||||
|
progress = serializers.FloatField(required=False)
|
||||||
|
command = serializers.ChoiceField(choices=["STOP", "KILL"], required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationQueryFilterSerializer(serializers.Serializer):
|
||||||
|
"""serialize notification query filter"""
|
||||||
|
|
||||||
|
filter = serializers.ChoiceField(
|
||||||
|
choices=["download", "settings", "channel"], required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PingUpdateSerializer(serializers.Serializer):
|
||||||
|
"""serialize update notification"""
|
||||||
|
|
||||||
|
status = serializers.BooleanField()
|
||||||
|
version = serializers.CharField()
|
||||||
|
is_breaking = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class PingSerializer(serializers.Serializer):
|
||||||
|
"""serialize ping response"""
|
||||||
|
|
||||||
|
response = serializers.ChoiceField(choices=["pong"])
|
||||||
|
user = serializers.IntegerField()
|
||||||
|
version = serializers.CharField()
|
||||||
|
ta_update = PingUpdateSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchedDataSerializer(serializers.Serializer):
|
||||||
|
"""mark as watched serializer"""
|
||||||
|
|
||||||
|
id = serializers.CharField()
|
||||||
|
is_watched = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshQuerySerializer(serializers.Serializer):
|
||||||
|
"""refresh query filtering"""
|
||||||
|
|
||||||
|
type = serializers.ChoiceField(
|
||||||
|
choices=["video", "channel", "playlist"], required=False
|
||||||
|
)
|
||||||
|
id = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshResponseSerializer(serializers.Serializer):
|
||||||
|
"""serialize refresh response"""
|
||||||
|
|
||||||
|
state = serializers.ChoiceField(
|
||||||
|
choices=["running", "queued", "empty", False]
|
||||||
|
)
|
||||||
|
total_queued = serializers.IntegerField()
|
||||||
|
in_queue_name = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshAddQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize add to refresh queue"""
|
||||||
|
|
||||||
|
extract_videos = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshAddDataSerializer(serializers.Serializer):
|
||||||
|
"""add to refresh queue serializer"""
|
||||||
|
|
||||||
|
video = serializers.ListField(
|
||||||
|
child=serializers.CharField(), required=False
|
||||||
|
)
|
||||||
|
channel = serializers.ListField(
|
||||||
|
child=serializers.CharField(), required=False
|
||||||
|
)
|
||||||
|
playlist = serializers.ListField(
|
||||||
|
child=serializers.CharField(), required=False
|
||||||
|
)
|
||||||
@@ -6,6 +6,14 @@ Functionality:
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
print("loading local dotenv")
|
||||||
|
load_dotenv(".env")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentSettings:
|
class EnvironmentSettings:
|
||||||
"""
|
"""
|
||||||
@@ -16,10 +24,10 @@ class EnvironmentSettings:
|
|||||||
|
|
||||||
HOST_UID: int = int(environ.get("HOST_UID", False))
|
HOST_UID: int = int(environ.get("HOST_UID", False))
|
||||||
HOST_GID: int = int(environ.get("HOST_GID", False))
|
HOST_GID: int = int(environ.get("HOST_GID", False))
|
||||||
ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST"))
|
DISABLE_STATIC_AUTH: bool = bool(environ.get("DISABLE_STATIC_AUTH"))
|
||||||
TZ: str = str(environ.get("TZ", "UTC"))
|
TZ: str = str(environ.get("TZ", "UTC"))
|
||||||
TA_PORT: int = int(environ.get("TA_PORT", False))
|
TA_PORT: int = int(environ.get("TA_PORT", False))
|
||||||
TA_UWSGI_PORT: int = int(environ.get("TA_UWSGI_PORT", False))
|
TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_PORT", False))
|
||||||
TA_USERNAME: str = str(environ.get("TA_USERNAME"))
|
TA_USERNAME: str = str(environ.get("TA_USERNAME"))
|
||||||
TA_PASSWORD: str = str(environ.get("TA_PASSWORD"))
|
TA_PASSWORD: str = str(environ.get("TA_PASSWORD"))
|
||||||
|
|
||||||
@@ -29,8 +37,7 @@ class EnvironmentSettings:
|
|||||||
CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache"))
|
CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache"))
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_HOST: str = str(environ.get("REDIS_HOST"))
|
REDIS_CON: str = str(environ.get("REDIS_CON"))
|
||||||
REDIS_PORT: int = int(environ.get("REDIS_PORT", 6379))
|
|
||||||
REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:"))
|
REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:"))
|
||||||
|
|
||||||
# ElasticSearch
|
# ElasticSearch
|
||||||
@@ -44,6 +51,20 @@ class EnvironmentSettings:
|
|||||||
)
|
)
|
||||||
ES_DISABLE_VERIFY_SSL: bool = bool(environ.get("ES_DISABLE_VERIFY_SSL"))
|
ES_DISABLE_VERIFY_SSL: bool = bool(environ.get("ES_DISABLE_VERIFY_SSL"))
|
||||||
|
|
||||||
|
def get_cache_root(self):
|
||||||
|
"""get root for web server"""
|
||||||
|
if self.CACHE_DIR.startswith("/"):
|
||||||
|
return self.CACHE_DIR
|
||||||
|
|
||||||
|
return f"/{self.CACHE_DIR}"
|
||||||
|
|
||||||
|
def get_media_root(self):
|
||||||
|
"""get root for media folder"""
|
||||||
|
if self.MEDIA_DIR.startswith("/"):
|
||||||
|
return self.MEDIA_DIR
|
||||||
|
|
||||||
|
return f"/{self.MEDIA_DIR}"
|
||||||
|
|
||||||
def print_generic(self):
|
def print_generic(self):
|
||||||
"""print generic env vars"""
|
"""print generic env vars"""
|
||||||
print(
|
print(
|
||||||
@@ -51,9 +72,9 @@ class EnvironmentSettings:
|
|||||||
HOST_UID: {self.HOST_UID}
|
HOST_UID: {self.HOST_UID}
|
||||||
HOST_GID: {self.HOST_GID}
|
HOST_GID: {self.HOST_GID}
|
||||||
TZ: {self.TZ}
|
TZ: {self.TZ}
|
||||||
ENABLE_CAST: {self.ENABLE_CAST}
|
DISABLE_STATIC_AUTH: {self.DISABLE_STATIC_AUTH}
|
||||||
TA_PORT: {self.TA_PORT}
|
TA_PORT: {self.TA_PORT}
|
||||||
TA_UWSGI_PORT: {self.TA_UWSGI_PORT}
|
TA_BACKEND_PORT: {self.TA_BACKEND_PORT}
|
||||||
TA_USERNAME: {self.TA_USERNAME}
|
TA_USERNAME: {self.TA_USERNAME}
|
||||||
TA_PASSWORD: *****"""
|
TA_PASSWORD: *****"""
|
||||||
)
|
)
|
||||||
@@ -71,8 +92,7 @@ class EnvironmentSettings:
|
|||||||
"""debug redis conf paths"""
|
"""debug redis conf paths"""
|
||||||
print(
|
print(
|
||||||
f"""
|
f"""
|
||||||
REDIS_HOST: {self.REDIS_HOST}
|
REDIS_CON: {self.REDIS_CON}
|
||||||
REDIS_PORT: {self.REDIS_PORT}
|
|
||||||
REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}"""
|
REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
|
||||||
|
|
||||||
class ElasticWrap:
|
class ElasticWrap:
|
||||||
@@ -1,224 +1,300 @@
|
|||||||
"""
|
"""
|
||||||
Loose collection of helper functions
|
Loose collection of helper functions
|
||||||
- don't import AppConfig class here to avoid circular imports
|
- don't import AppConfig class here to avoid circular imports
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urlparse
|
from time import sleep
|
||||||
|
from typing import Any
|
||||||
import requests
|
from urllib.parse import urlparse
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
|
||||||
|
import requests
|
||||||
|
from common.src.es_connect import IndexPaginate
|
||||||
def ignore_filelist(filelist: list[str]) -> list[str]:
|
|
||||||
"""ignore temp files for os.listdir sanitizer"""
|
|
||||||
to_ignore = [
|
def ignore_filelist(filelist: list[str]) -> list[str]:
|
||||||
"@eaDir",
|
"""ignore temp files for os.listdir sanitizer"""
|
||||||
"Icon\r\r",
|
to_ignore = [
|
||||||
"Network Trash Folder",
|
"@eaDir",
|
||||||
"Temporary Items",
|
"Icon\r\r",
|
||||||
]
|
"Network Trash Folder",
|
||||||
cleaned: list[str] = []
|
"Temporary Items",
|
||||||
for file_name in filelist:
|
]
|
||||||
if file_name.startswith(".") or file_name in to_ignore:
|
cleaned: list[str] = []
|
||||||
continue
|
for file_name in filelist:
|
||||||
|
if file_name.startswith(".") or file_name in to_ignore:
|
||||||
cleaned.append(file_name)
|
continue
|
||||||
|
|
||||||
return cleaned
|
cleaned.append(file_name)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
def randomizor(length: int) -> str:
|
|
||||||
"""generate random alpha numeric string"""
|
|
||||||
pool: str = string.digits + string.ascii_letters
|
def randomizor(length: int) -> str:
|
||||||
return "".join(random.choice(pool) for i in range(length))
|
"""generate random alpha numeric string"""
|
||||||
|
pool: str = string.digits + string.ascii_letters
|
||||||
|
return "".join(random.choice(pool) for i in range(length))
|
||||||
def requests_headers() -> dict[str, str]:
|
|
||||||
"""build header with random user agent for requests outside of yt-dlp"""
|
|
||||||
|
def rand_sleep(config) -> None:
|
||||||
chrome_versions = (
|
"""randomized sleep based on config"""
|
||||||
"90.0.4430.212",
|
sleep_config = config["downloads"].get("sleep_interval")
|
||||||
"90.0.4430.24",
|
if not sleep_config:
|
||||||
"90.0.4430.70",
|
return
|
||||||
"90.0.4430.72",
|
|
||||||
"90.0.4430.85",
|
secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
|
||||||
"90.0.4430.93",
|
sleep(secs)
|
||||||
"91.0.4472.101",
|
|
||||||
"91.0.4472.106",
|
|
||||||
"91.0.4472.114",
|
def requests_headers() -> dict[str, str]:
|
||||||
"91.0.4472.124",
|
"""build header with random user agent for requests outside of yt-dlp"""
|
||||||
"91.0.4472.164",
|
|
||||||
"91.0.4472.19",
|
chrome_versions = (
|
||||||
"91.0.4472.77",
|
"90.0.4430.212",
|
||||||
"92.0.4515.107",
|
"90.0.4430.24",
|
||||||
"92.0.4515.115",
|
"90.0.4430.70",
|
||||||
"92.0.4515.131",
|
"90.0.4430.72",
|
||||||
"92.0.4515.159",
|
"90.0.4430.85",
|
||||||
"92.0.4515.43",
|
"90.0.4430.93",
|
||||||
"93.0.4556.0",
|
"91.0.4472.101",
|
||||||
"93.0.4577.15",
|
"91.0.4472.106",
|
||||||
"93.0.4577.63",
|
"91.0.4472.114",
|
||||||
"93.0.4577.82",
|
"91.0.4472.124",
|
||||||
"94.0.4606.41",
|
"91.0.4472.164",
|
||||||
"94.0.4606.54",
|
"91.0.4472.19",
|
||||||
"94.0.4606.61",
|
"91.0.4472.77",
|
||||||
"94.0.4606.71",
|
"92.0.4515.107",
|
||||||
"94.0.4606.81",
|
"92.0.4515.115",
|
||||||
"94.0.4606.85",
|
"92.0.4515.131",
|
||||||
"95.0.4638.17",
|
"92.0.4515.159",
|
||||||
"95.0.4638.50",
|
"92.0.4515.43",
|
||||||
"95.0.4638.54",
|
"93.0.4556.0",
|
||||||
"95.0.4638.69",
|
"93.0.4577.15",
|
||||||
"95.0.4638.74",
|
"93.0.4577.63",
|
||||||
"96.0.4664.18",
|
"93.0.4577.82",
|
||||||
"96.0.4664.45",
|
"94.0.4606.41",
|
||||||
"96.0.4664.55",
|
"94.0.4606.54",
|
||||||
"96.0.4664.93",
|
"94.0.4606.61",
|
||||||
"97.0.4692.20",
|
"94.0.4606.71",
|
||||||
)
|
"94.0.4606.81",
|
||||||
template = (
|
"94.0.4606.85",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"95.0.4638.17",
|
||||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
"95.0.4638.50",
|
||||||
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
"95.0.4638.54",
|
||||||
)
|
"95.0.4638.69",
|
||||||
|
"95.0.4638.74",
|
||||||
return {"User-Agent": template}
|
"96.0.4664.18",
|
||||||
|
"96.0.4664.45",
|
||||||
|
"96.0.4664.55",
|
||||||
def date_praser(timestamp: int | str) -> str:
|
"96.0.4664.93",
|
||||||
"""return formatted date string"""
|
"97.0.4692.20",
|
||||||
if isinstance(timestamp, int):
|
)
|
||||||
date_obj = datetime.fromtimestamp(timestamp)
|
template = (
|
||||||
elif isinstance(timestamp, str):
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
||||||
return date_obj.date().isoformat()
|
)
|
||||||
|
|
||||||
|
return {"User-Agent": template}
|
||||||
def time_parser(timestamp: str) -> float:
|
|
||||||
"""return seconds from timestamp, false on empty"""
|
|
||||||
if not timestamp:
|
def date_parser(timestamp: int | str) -> str:
|
||||||
return False
|
"""return formatted date string"""
|
||||||
|
if isinstance(timestamp, int):
|
||||||
if timestamp.isnumeric():
|
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
return int(timestamp)
|
elif isinstance(timestamp, str):
|
||||||
|
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
||||||
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||||
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
else:
|
||||||
|
raise TypeError(f"invalid timestamp: {timestamp}")
|
||||||
|
|
||||||
def clear_dl_cache(cache_dir: str) -> int:
|
return date_obj.isoformat()
|
||||||
"""clear leftover files from dl cache"""
|
|
||||||
print("clear download cache")
|
|
||||||
download_cache_dir = os.path.join(cache_dir, "download")
|
def time_parser(timestamp: str) -> float:
|
||||||
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
"""return seconds from timestamp, false on empty"""
|
||||||
for cached in leftover_files:
|
if not timestamp:
|
||||||
to_delete = os.path.join(download_cache_dir, cached)
|
return False
|
||||||
os.remove(to_delete)
|
|
||||||
|
if timestamp.isnumeric():
|
||||||
return len(leftover_files)
|
return int(timestamp)
|
||||||
|
|
||||||
|
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
||||||
def get_mapping() -> dict:
|
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
||||||
"""read index_mapping.json and get expected mapping and settings"""
|
|
||||||
with open("home/src/es/index_mapping.json", "r", encoding="utf-8") as f:
|
|
||||||
index_config: dict = json.load(f).get("index_config")
|
def clear_dl_cache(cache_dir: str) -> int:
|
||||||
|
"""clear leftover files from dl cache"""
|
||||||
return index_config
|
print("clear download cache")
|
||||||
|
download_cache_dir = os.path.join(cache_dir, "download")
|
||||||
|
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
||||||
def is_shorts(youtube_id: str) -> bool:
|
for cached in leftover_files:
|
||||||
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
to_delete = os.path.join(download_cache_dir, cached)
|
||||||
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
os.remove(to_delete)
|
||||||
response = requests.head(
|
|
||||||
shorts_url, headers=requests_headers(), timeout=10
|
return len(leftover_files)
|
||||||
)
|
|
||||||
|
|
||||||
return response.status_code == 200
|
def get_mapping() -> dict:
|
||||||
|
"""read index_mapping.json and get expected mapping and settings"""
|
||||||
|
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
|
||||||
def get_duration_sec(file_path: str) -> int:
|
index_config: dict = json.load(f).get("index_config")
|
||||||
"""get duration of media file from file path"""
|
|
||||||
|
return index_config
|
||||||
duration = subprocess.run(
|
|
||||||
[
|
|
||||||
"ffprobe",
|
def is_shorts(youtube_id: str) -> bool:
|
||||||
"-v",
|
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
||||||
"error",
|
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
||||||
"-show_entries",
|
cookies = {"SOCS": "CAI"}
|
||||||
"format=duration",
|
response = requests.head(
|
||||||
"-of",
|
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
|
||||||
"default=noprint_wrappers=1:nokey=1",
|
)
|
||||||
file_path,
|
|
||||||
],
|
return response.status_code == 200
|
||||||
capture_output=True,
|
|
||||||
check=True,
|
|
||||||
)
|
def get_duration_sec(file_path: str) -> int:
|
||||||
duration_raw = duration.stdout.decode().strip()
|
"""get duration of media file from file path"""
|
||||||
if duration_raw == "N/A":
|
|
||||||
return 0
|
duration = subprocess.run(
|
||||||
|
[
|
||||||
duration_sec = int(float(duration_raw))
|
"ffprobe",
|
||||||
return duration_sec
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
def get_duration_str(seconds: int) -> str:
|
"format=duration",
|
||||||
"""Return a human-readable duration string from seconds."""
|
"-of",
|
||||||
if not seconds:
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
return "NA"
|
file_path,
|
||||||
|
],
|
||||||
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
capture_output=True,
|
||||||
duration_parts = []
|
check=True,
|
||||||
|
)
|
||||||
for unit_label, unit_seconds in units:
|
duration_raw = duration.stdout.decode().strip()
|
||||||
if seconds >= unit_seconds:
|
if duration_raw == "N/A":
|
||||||
unit_count, seconds = divmod(seconds, unit_seconds)
|
return 0
|
||||||
duration_parts.append(f"{unit_count:02}{unit_label}")
|
|
||||||
|
duration_sec = int(float(duration_raw))
|
||||||
return " ".join(duration_parts)
|
return duration_sec
|
||||||
|
|
||||||
|
|
||||||
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
def get_duration_str(seconds: int) -> str:
|
||||||
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
"""Return a human-readable duration string from seconds."""
|
||||||
allowed_hosts: list[str] = [
|
if not seconds:
|
||||||
"localhost",
|
return "NA"
|
||||||
"tubearchivist",
|
|
||||||
]
|
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
||||||
csrf_trusted_origins: list[str] = [
|
duration_parts = []
|
||||||
"http://localhost",
|
|
||||||
"http://tubearchivist",
|
for unit_label, unit_seconds in units:
|
||||||
]
|
if seconds >= unit_seconds:
|
||||||
for host in ta_host.split():
|
unit_count, seconds = divmod(seconds, unit_seconds)
|
||||||
host_clean = host.strip()
|
duration_parts.append(f"{unit_count:02}{unit_label}")
|
||||||
if not host_clean.startswith("http"):
|
|
||||||
host_clean = f"http://{host_clean}"
|
duration_parts[0] = duration_parts[0].lstrip("0")
|
||||||
|
|
||||||
parsed = urlparse(host_clean)
|
return " ".join(duration_parts)
|
||||||
allowed_hosts.append(f"{parsed.hostname}")
|
|
||||||
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
|
|
||||||
|
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
||||||
return allowed_hosts, csrf_trusted_origins
|
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
||||||
|
allowed_hosts: list[str] = [
|
||||||
|
"localhost",
|
||||||
def get_stylesheets():
|
"tubearchivist",
|
||||||
"""Get all valid stylesheets from /static/css"""
|
]
|
||||||
app_root = EnvironmentSettings.APP_DIR
|
csrf_trusted_origins: list[str] = [
|
||||||
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
|
"http://localhost",
|
||||||
stylesheets.remove("style.css")
|
"http://tubearchivist",
|
||||||
stylesheets.sort()
|
]
|
||||||
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
|
for host in ta_host.split():
|
||||||
return stylesheets
|
host_clean = host.strip()
|
||||||
|
if not host_clean.startswith("http"):
|
||||||
|
host_clean = f"http://{host_clean}"
|
||||||
def check_stylesheet(stylesheet: str):
|
|
||||||
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
parsed = urlparse(host_clean)
|
||||||
if stylesheet in get_stylesheets():
|
allowed_hosts.append(f"{parsed.hostname}")
|
||||||
return stylesheet
|
cors_url = f"{parsed.scheme}://{parsed.hostname}"
|
||||||
|
|
||||||
return "dark.css"
|
if parsed.port:
|
||||||
|
cors_url = f"{cors_url}:{parsed.port}"
|
||||||
|
|
||||||
|
csrf_trusted_origins.append(cors_url)
|
||||||
|
|
||||||
|
return allowed_hosts, csrf_trusted_origins
|
||||||
|
|
||||||
|
|
||||||
|
def get_stylesheets() -> list:
|
||||||
|
"""Get all valid stylesheets from /static/css"""
|
||||||
|
|
||||||
|
stylesheets = [
|
||||||
|
"dark.css",
|
||||||
|
"light.css",
|
||||||
|
"matrix.css",
|
||||||
|
"midnight.css",
|
||||||
|
"custom.css",
|
||||||
|
]
|
||||||
|
return stylesheets
|
||||||
|
|
||||||
|
|
||||||
|
def check_stylesheet(stylesheet: str):
|
||||||
|
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
||||||
|
if stylesheet in get_stylesheets():
|
||||||
|
return stylesheet
|
||||||
|
|
||||||
|
return "dark.css"
|
||||||
|
|
||||||
|
|
||||||
|
def is_missing(
|
||||||
|
to_check: str | list[str],
|
||||||
|
index_name: str = "ta_video,ta_download",
|
||||||
|
on_key: str = "youtube_id",
|
||||||
|
) -> list[str]:
|
||||||
|
"""id or list of ids that are missing from index_name"""
|
||||||
|
if isinstance(to_check, str):
|
||||||
|
to_check = [to_check]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"query": {"terms": {on_key: to_check}},
|
||||||
|
"_source": [on_key],
|
||||||
|
}
|
||||||
|
result = IndexPaginate(index_name, data=data).get_results()
|
||||||
|
existing_ids = [i[on_key] for i in result]
|
||||||
|
dl = [i for i in to_check if i not in existing_ids]
|
||||||
|
|
||||||
|
return dl
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
||||||
|
"""get overwrites indexed my channel_id"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
|
||||||
|
},
|
||||||
|
"_source": ["channel_id", "channel_overwrites"],
|
||||||
|
}
|
||||||
|
result = IndexPaginate("ta_channel", data).get_results()
|
||||||
|
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
||||||
|
|
||||||
|
return overwrites
|
||||||
|
|
||||||
|
|
||||||
|
def calc_is_watched(duration: float, position: float) -> bool:
|
||||||
|
"""considered watched based on duration position"""
|
||||||
|
|
||||||
|
if not duration or duration <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if duration < 60:
|
||||||
|
threshold = 0.5
|
||||||
|
elif duration > 900:
|
||||||
|
threshold = 1 - (180 / duration)
|
||||||
|
else:
|
||||||
|
threshold = 0.9
|
||||||
|
|
||||||
|
return position >= duration * threshold
|
||||||
@@ -5,10 +5,10 @@ functionality:
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.es.connect import ElasticWrap
|
from common.src.es_connect import ElasticWrap
|
||||||
from home.src.ta.config import AppConfig
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from home.src.ta.users import UserConfig
|
from user.src.user_config import UserConfig
|
||||||
|
|
||||||
|
|
||||||
class YouTubeItem:
|
class YouTubeItem:
|
||||||
@@ -17,7 +17,7 @@ class YouTubeItem:
|
|||||||
es_path = False
|
es_path = False
|
||||||
index_name = ""
|
index_name = ""
|
||||||
yt_base = ""
|
yt_base = ""
|
||||||
yt_obs = {
|
yt_obs: dict[str, bool | str] = {
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
"noplaylist": True,
|
"noplaylist": True,
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,9 @@ class Pagination:
|
|||||||
def first_guess(self):
|
def first_guess(self):
|
||||||
"""build first guess before api call"""
|
"""build first guess before api call"""
|
||||||
page_get = self.page_get
|
page_get = self.page_get
|
||||||
|
page_from = 0
|
||||||
if page_get in [0, 1]:
|
if page_get in [0, 1]:
|
||||||
page_from = 0
|
prev_pages = None
|
||||||
prev_pages = False
|
|
||||||
elif page_get > 1:
|
elif page_get > 1:
|
||||||
page_from = (page_get - 1) * self.page_size
|
page_from = (page_get - 1) * self.page_size
|
||||||
prev_pages = [
|
prev_pages = [
|
||||||
@@ -6,19 +6,19 @@ Functionality:
|
|||||||
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.ta.helper import date_praser, get_duration_str
|
from common.src.helper import date_parser, get_duration_str
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from download.src.thumbnails import ThumbManager
|
||||||
|
|
||||||
|
|
||||||
class SearchProcess:
|
class SearchProcess:
|
||||||
"""process search results"""
|
"""process search results"""
|
||||||
|
|
||||||
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
def __init__(self, response, match_video_user_progress: None | int = None):
|
||||||
|
|
||||||
def __init__(self, response):
|
|
||||||
self.response = response
|
self.response = response
|
||||||
self.processed = False
|
self.processed = False
|
||||||
|
self.position_index = self.get_user_progress(match_video_user_progress)
|
||||||
|
|
||||||
def process(self):
|
def process(self):
|
||||||
"""detect type and process"""
|
"""detect type and process"""
|
||||||
@@ -35,6 +35,23 @@ class SearchProcess:
|
|||||||
|
|
||||||
return self.processed
|
return self.processed
|
||||||
|
|
||||||
|
def get_user_progress(self, match_video_user_progress) -> dict | None:
|
||||||
|
"""get user video watch progress"""
|
||||||
|
if not match_video_user_progress:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = f"{match_video_user_progress}:progress:*"
|
||||||
|
all_positions = RedisArchivist().list_items(query)
|
||||||
|
if not all_positions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pos_index = {
|
||||||
|
i["youtube_id"]: i["position"]
|
||||||
|
for i in all_positions
|
||||||
|
if not i.get("watched")
|
||||||
|
}
|
||||||
|
return pos_index
|
||||||
|
|
||||||
def _process_result(self, result):
|
def _process_result(self, result):
|
||||||
"""detect which type of data to process"""
|
"""detect which type of data to process"""
|
||||||
index = result["_index"]
|
index = result["_index"]
|
||||||
@@ -66,8 +83,9 @@ class SearchProcess:
|
|||||||
def _process_channel(channel_dict):
|
def _process_channel(channel_dict):
|
||||||
"""run on single channel"""
|
"""run on single channel"""
|
||||||
channel_id = channel_dict["channel_id"]
|
channel_id = channel_dict["channel_id"]
|
||||||
art_base = f"/cache/channels/{channel_id}"
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
date_str = date_praser(channel_dict["channel_last_refresh"])
|
art_base = f"{cache_root}/channels/{channel_id}"
|
||||||
|
date_str = date_parser(channel_dict["channel_last_refresh"])
|
||||||
channel_dict.update(
|
channel_dict.update(
|
||||||
{
|
{
|
||||||
"channel_last_refresh": date_str,
|
"channel_last_refresh": date_str,
|
||||||
@@ -83,38 +101,62 @@ class SearchProcess:
|
|||||||
"""run on single video dict"""
|
"""run on single video dict"""
|
||||||
video_id = video_dict["youtube_id"]
|
video_id = video_dict["youtube_id"]
|
||||||
media_url = urllib.parse.quote(video_dict["media_url"])
|
media_url = urllib.parse.quote(video_dict["media_url"])
|
||||||
vid_last_refresh = date_praser(video_dict["vid_last_refresh"])
|
vid_last_refresh = date_parser(video_dict["vid_last_refresh"])
|
||||||
published = date_praser(video_dict["published"])
|
published = date_parser(video_dict["published"])
|
||||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
||||||
channel = self._process_channel(video_dict["channel"])
|
channel = self._process_channel(video_dict["channel"])
|
||||||
|
|
||||||
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
|
media_root = EnvironmentSettings().get_media_root()
|
||||||
|
|
||||||
if "subtitles" in video_dict:
|
if "subtitles" in video_dict:
|
||||||
for idx, _ in enumerate(video_dict["subtitles"]):
|
for idx, _ in enumerate(video_dict["subtitles"]):
|
||||||
url = video_dict["subtitles"][idx]["media_url"]
|
url = video_dict["subtitles"][idx]["media_url"]
|
||||||
video_dict["subtitles"][idx]["media_url"] = f"/media/{url}"
|
video_dict["subtitles"][idx][
|
||||||
|
"media_url"
|
||||||
|
] = f"{media_root}/{url}"
|
||||||
|
else:
|
||||||
|
video_dict["subtitles"] = []
|
||||||
|
|
||||||
video_dict.update(
|
video_dict.update(
|
||||||
{
|
{
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"media_url": f"/media/{media_url}",
|
"media_url": f"{media_root}/{media_url}",
|
||||||
"vid_last_refresh": vid_last_refresh,
|
"vid_last_refresh": vid_last_refresh,
|
||||||
"published": published,
|
"published": published,
|
||||||
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
|
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.position_index:
|
||||||
|
player_position = self.position_index.get(video_id)
|
||||||
|
total = video_dict["player"].get("duration")
|
||||||
|
if player_position and total:
|
||||||
|
progress = 100 * (player_position / total)
|
||||||
|
video_dict["player"].update(
|
||||||
|
{
|
||||||
|
"progress": progress,
|
||||||
|
"position": player_position,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if "playlist" not in video_dict:
|
||||||
|
video_dict["playlist"] = []
|
||||||
|
|
||||||
return dict(sorted(video_dict.items()))
|
return dict(sorted(video_dict.items()))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _process_playlist(playlist_dict):
|
def _process_playlist(playlist_dict):
|
||||||
"""run on single playlist dict"""
|
"""run on single playlist dict"""
|
||||||
playlist_id = playlist_dict["playlist_id"]
|
playlist_id = playlist_dict["playlist_id"]
|
||||||
playlist_last_refresh = date_praser(
|
playlist_last_refresh = date_parser(
|
||||||
playlist_dict["playlist_last_refresh"]
|
playlist_dict["playlist_last_refresh"]
|
||||||
)
|
)
|
||||||
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
|
playlist_thumbnail = f"{cache_root}/playlists/{playlist_id}.jpg"
|
||||||
playlist_dict.update(
|
playlist_dict.update(
|
||||||
{
|
{
|
||||||
"playlist_thumbnail": f"/cache/playlists/{playlist_id}.jpg",
|
"playlist_thumbnail": playlist_thumbnail,
|
||||||
"playlist_last_refresh": playlist_last_refresh,
|
"playlist_last_refresh": playlist_last_refresh,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -124,12 +166,13 @@ class SearchProcess:
|
|||||||
def _process_download(self, download_dict):
|
def _process_download(self, download_dict):
|
||||||
"""run on single download item"""
|
"""run on single download item"""
|
||||||
video_id = download_dict["youtube_id"]
|
video_id = download_dict["youtube_id"]
|
||||||
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
||||||
published = date_praser(download_dict["published"])
|
published = date_parser(download_dict["published"])
|
||||||
|
|
||||||
download_dict.update(
|
download_dict.update(
|
||||||
{
|
{
|
||||||
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
|
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
|
||||||
"published": published,
|
"published": published,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -6,8 +6,8 @@ Functionality:
|
|||||||
- calculate pagination values
|
- calculate pagination values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.src.search_processor import SearchProcess
|
from common.src.es_connect import ElasticWrap
|
||||||
from home.src.es.connect import ElasticWrap
|
from common.src.search_processor import SearchProcess
|
||||||
|
|
||||||
|
|
||||||
class SearchForm:
|
class SearchForm:
|
||||||
@@ -8,7 +8,7 @@ functionality:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
|
||||||
|
|
||||||
class RedisBase:
|
class RedisBase:
|
||||||
@@ -17,10 +17,8 @@ class RedisBase:
|
|||||||
NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE
|
NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = redis.Redis(
|
self.conn = redis.from_url(
|
||||||
host=EnvironmentSettings.REDIS_HOST,
|
url=EnvironmentSettings.REDIS_CON, decode_responses=True
|
||||||
port=EnvironmentSettings.REDIS_PORT,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,15 +38,15 @@ class RedisArchivist(RedisBase):
|
|||||||
def set_message(
|
def set_message(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
message: dict,
|
message: dict | str,
|
||||||
path: str = ".",
|
|
||||||
expire: bool | int = False,
|
expire: bool | int = False,
|
||||||
save: bool = False,
|
save: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""write new message to redis"""
|
"""write new message to redis"""
|
||||||
self.conn.execute_command(
|
to_write = (
|
||||||
"JSON.SET", self.NAME_SPACE + key, path, json.dumps(message)
|
json.dumps(message) if isinstance(message, dict) else message
|
||||||
)
|
)
|
||||||
|
self.conn.execute_command("SET", self.NAME_SPACE + key, to_write)
|
||||||
|
|
||||||
if expire:
|
if expire:
|
||||||
if isinstance(expire, bool):
|
if isinstance(expire, bool):
|
||||||
@@ -67,8 +65,24 @@ class RedisArchivist(RedisBase):
|
|||||||
except redis.exceptions.ResponseError:
|
except redis.exceptions.ResponseError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_message(self, key: str) -> dict:
|
def get_message_str(self, key: str) -> str | None:
|
||||||
"""get message dict from redis"""
|
"""get message string"""
|
||||||
|
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def get_message_dict(self, key: str) -> dict:
|
||||||
|
"""get message dict"""
|
||||||
|
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
|
||||||
|
if not reply:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(reply)
|
||||||
|
|
||||||
|
def get_message(self, key: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
get message dict from redis
|
||||||
|
old json get message, only used for migration, to be removed later
|
||||||
|
"""
|
||||||
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
|
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
|
||||||
if reply:
|
if reply:
|
||||||
return json.loads(reply)
|
return json.loads(reply)
|
||||||
@@ -91,11 +105,14 @@ class RedisArchivist(RedisBase):
|
|||||||
if not all_matches:
|
if not all_matches:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [self.get_message(i) for i in all_matches]
|
return [self.get_message_dict(i) for i in all_matches]
|
||||||
|
|
||||||
def del_message(self, key: str) -> bool:
|
def del_message(self, key: str, save: bool = False) -> bool:
|
||||||
"""delete key from redis"""
|
"""delete key from redis"""
|
||||||
response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
|
response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
|
||||||
|
if save:
|
||||||
|
self.bg_save()
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +121,17 @@ class RedisQueue(RedisBase):
|
|||||||
dynamically interact with queues in redis using sorted set
|
dynamically interact with queues in redis using sorted set
|
||||||
- low score number is first in queue
|
- low score number is first in queue
|
||||||
- add new items with high score number
|
- add new items with high score number
|
||||||
|
|
||||||
|
queue names in use:
|
||||||
|
download:channel channels during download
|
||||||
|
download:playlist:full playlists during dl for full refresh
|
||||||
|
download:playlist:quick playlists during dl for quick refresh
|
||||||
|
download:video videos during downloads
|
||||||
|
index:comment videos needing comment indexing
|
||||||
|
reindex:ta_video reindex videos
|
||||||
|
reindex:ta_channel reindex channels
|
||||||
|
reindex:ta_playlist reindex playlists
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, queue_name: str):
|
def __init__(self, queue_name: str):
|
||||||
@@ -127,18 +155,48 @@ class RedisQueue(RedisBase):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def add(self, to_add: str) -> None:
|
||||||
|
"""add single item to queue"""
|
||||||
|
if not to_add:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_score = self._get_next_score()
|
||||||
|
self.conn.zadd(self.key, {to_add: next_score})
|
||||||
|
|
||||||
def add_list(self, to_add: list) -> None:
|
def add_list(self, to_add: list) -> None:
|
||||||
"""add list to queue"""
|
"""add list to queue"""
|
||||||
mapping = {i: "+inf" for i in to_add}
|
if not to_add:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_score = self._get_next_score()
|
||||||
|
mapping = {i[1]: next_score + i[0] for i in enumerate(to_add)}
|
||||||
self.conn.zadd(self.key, mapping)
|
self.conn.zadd(self.key, mapping)
|
||||||
|
|
||||||
def get_next(self) -> str | bool:
|
def max_score(self) -> int | None:
|
||||||
|
"""get max score"""
|
||||||
|
last = self.conn.zrange(self.key, -1, -1, withscores=True)
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return int(last[0][1])
|
||||||
|
|
||||||
|
def _get_next_score(self) -> float:
|
||||||
|
"""get next score in queue to append"""
|
||||||
|
last = self.conn.zrange(self.key, -1, -1, withscores=True)
|
||||||
|
if not last:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
return last[0][1] + 1
|
||||||
|
|
||||||
|
def get_next(self) -> tuple[str | None, int | None]:
|
||||||
"""return next element in the queue, if available"""
|
"""return next element in the queue, if available"""
|
||||||
result = self.conn.zpopmin(self.key)
|
result = self.conn.zpopmin(self.key)
|
||||||
if not result:
|
if not result:
|
||||||
return False
|
return None, None
|
||||||
|
|
||||||
return result[0][0]
|
item, idx = result[0][0], int(result[0][1])
|
||||||
|
|
||||||
|
return item, idx
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""delete list from redis"""
|
"""delete list from redis"""
|
||||||
@@ -6,15 +6,20 @@ Functionality:
|
|||||||
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from common.src.ta_redis import RedisArchivist
|
||||||
from home.src.index.video_constants import VideoTypeEnum
|
from download.src.yt_dlp_base import YtWrap
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
class Parser:
|
class Parser:
|
||||||
"""take a multi line string and detect valid youtube ids"""
|
"""
|
||||||
|
take a multi line string and detect valid youtube ids
|
||||||
|
channel handle lookup is cached, can be disabled for unittests
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, url_str):
|
def __init__(self, url_str, use_cache=True):
|
||||||
self.url_list = [i.strip() for i in url_str.split()]
|
self.url_list = [i.strip() for i in url_str.split()]
|
||||||
|
self.use_cache = use_cache
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
"""parse the list"""
|
"""parse the list"""
|
||||||
@@ -42,6 +47,10 @@ class Parser:
|
|||||||
youtube_id = parsed.path.strip("/")
|
youtube_id = parsed.path.strip("/")
|
||||||
return self._validate_expected(youtube_id, "video")
|
return self._validate_expected(youtube_id, "video")
|
||||||
|
|
||||||
|
if "youtube.com" not in parsed.netloc:
|
||||||
|
message = f"invalid domain: {parsed.netloc}"
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
query_parsed = parse_qs(parsed.query)
|
query_parsed = parse_qs(parsed.query)
|
||||||
if "v" in query_parsed:
|
if "v" in query_parsed:
|
||||||
# video from v query str
|
# video from v query str
|
||||||
@@ -63,6 +72,9 @@ class Parser:
|
|||||||
if all_paths[0] == "channel":
|
if all_paths[0] == "channel":
|
||||||
return self._validate_expected(all_paths[1], "channel")
|
return self._validate_expected(all_paths[1], "channel")
|
||||||
|
|
||||||
|
if all_paths[0] == "live":
|
||||||
|
return self._validate_expected(all_paths[1], "video")
|
||||||
|
|
||||||
# detect channel
|
# detect channel
|
||||||
channel_id = self._extract_channel_name(parsed.geturl())
|
channel_id = self._extract_channel_name(parsed.geturl())
|
||||||
return {"type": "channel", "url": channel_id}
|
return {"type": "channel", "url": channel_id}
|
||||||
@@ -99,9 +111,13 @@ class Parser:
|
|||||||
|
|
||||||
return {"type": item_type, "url": id_str}
|
return {"type": item_type, "url": id_str}
|
||||||
|
|
||||||
@staticmethod
|
def _extract_channel_name(self, url):
|
||||||
def _extract_channel_name(url):
|
"""find channel id from channel name with yt-dlp help, cache result"""
|
||||||
"""find channel id from channel name with yt-dlp help"""
|
if self.use_cache:
|
||||||
|
cached = self._get_cached(url)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
obs_request = {
|
obs_request = {
|
||||||
"check_formats": None,
|
"check_formats": None,
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
@@ -109,8 +125,14 @@ class Parser:
|
|||||||
"playlistend": 0,
|
"playlistend": 0,
|
||||||
}
|
}
|
||||||
url_info = YtWrap(obs_request).extract(url)
|
url_info = YtWrap(obs_request).extract(url)
|
||||||
|
if not url_info:
|
||||||
|
raise ValueError(f"failed to retrieve content from URL: {url}")
|
||||||
|
|
||||||
channel_id = url_info.get("channel_id", False)
|
channel_id = url_info.get("channel_id", False)
|
||||||
if channel_id:
|
if channel_id:
|
||||||
|
if self.use_cache:
|
||||||
|
self._set_cache(url, channel_id)
|
||||||
|
|
||||||
return channel_id
|
return channel_id
|
||||||
|
|
||||||
url = url_info.get("url", False)
|
url = url_info.get("url", False)
|
||||||
@@ -123,6 +145,42 @@ class Parser:
|
|||||||
print(f"failed to extract channel id from {url}")
|
print(f"failed to extract channel id from {url}")
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_cached(url) -> str | None:
|
||||||
|
"""get cached channel ID, if available"""
|
||||||
|
path = urlparse(url).path.lstrip("/")
|
||||||
|
if not path.startswith("@"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
handle = path.split("/")[0]
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = f"channel:handlesearch:{handle.lower()}"
|
||||||
|
cached = RedisArchivist().get_message_dict(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached["channel_id"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_cache(url, channel_id) -> None:
|
||||||
|
"""set cache"""
|
||||||
|
path = urlparse(url).path.lstrip("/")
|
||||||
|
if not path.startswith("@"):
|
||||||
|
return
|
||||||
|
|
||||||
|
handle = path.split("/")[0]
|
||||||
|
if not handle:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = f"channel:handlesearch:{handle.lower()}"
|
||||||
|
message = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"handle": handle,
|
||||||
|
}
|
||||||
|
RedisArchivist().set_message(cache_key, message, expire=3600 * 24 * 7)
|
||||||
|
|
||||||
def _detect_vid_type(self, path):
|
def _detect_vid_type(self, path):
|
||||||
"""try to match enum from path, needs to be serializable"""
|
"""try to match enum from path, needs to be serializable"""
|
||||||
last = path.strip("/").split("/")[-1]
|
last = path.strip("/").split("/")[-1]
|
||||||
@@ -5,16 +5,18 @@ functionality:
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.es.connect import ElasticWrap
|
from common.src.es_connect import ElasticWrap
|
||||||
from home.src.ta.urlparser import Parser
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from common.src.urlparser import Parser
|
||||||
|
|
||||||
|
|
||||||
class WatchState:
|
class WatchState:
|
||||||
"""handle watched checkbox for videos and channels"""
|
"""handle watched checkbox for videos and channels"""
|
||||||
|
|
||||||
def __init__(self, youtube_id, is_watched):
|
def __init__(self, youtube_id: str, is_watched: bool, user_id: int):
|
||||||
self.youtube_id = youtube_id
|
self.youtube_id = youtube_id
|
||||||
self.is_watched = is_watched
|
self.is_watched = is_watched
|
||||||
|
self.user_id = user_id
|
||||||
self.stamp = int(datetime.now().timestamp())
|
self.stamp = int(datetime.now().timestamp())
|
||||||
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
|
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
|
||||||
|
|
||||||
@@ -41,15 +43,12 @@ class WatchState:
|
|||||||
def change_vid_state(self):
|
def change_vid_state(self):
|
||||||
"""change watched state of video"""
|
"""change watched state of video"""
|
||||||
path = f"ta_video/_update/{self.youtube_id}"
|
path = f"ta_video/_update/{self.youtube_id}"
|
||||||
data = {
|
data = {"doc": {"player": {"watched": self.is_watched}}}
|
||||||
"doc": {
|
if self.is_watched:
|
||||||
"player": {
|
data["doc"]["player"]["watched_date"] = self.stamp
|
||||||
"watched": self.is_watched,
|
|
||||||
"watched_date": self.stamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response, status_code = ElasticWrap(path).post(data=data)
|
response, status_code = ElasticWrap(path).post(data=data)
|
||||||
|
key = f"{self.user_id}:progress:{self.youtube_id}"
|
||||||
|
RedisArchivist().del_message(key)
|
||||||
if status_code != 200:
|
if status_code != 200:
|
||||||
print(response)
|
print(response)
|
||||||
raise ValueError("failed to mark video as watched")
|
raise ValueError("failed to mark video as watched")
|
||||||
11
backend/common/tests/conftest.py
Normal file
11
backend/common/tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""test configs"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def change_test_dir(request):
|
||||||
|
"""change directory to project folder"""
|
||||||
|
os.chdir(request.config.rootdir / "backend")
|
||||||
113
backend/common/tests/test_src/test_helper.py
Normal file
113
backend/common/tests/test_src/test_helper.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""tests for helper functions"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from common.src.helper import (
|
||||||
|
date_parser,
|
||||||
|
get_duration_str,
|
||||||
|
get_mapping,
|
||||||
|
is_shorts,
|
||||||
|
randomizor,
|
||||||
|
time_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_randomizor_with_positive_length():
|
||||||
|
"""test randomizer"""
|
||||||
|
length = 10
|
||||||
|
result = randomizor(length)
|
||||||
|
assert len(result) == length
|
||||||
|
assert result.isalnum()
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_parser_with_int():
|
||||||
|
"""unix timestamp"""
|
||||||
|
timestamp = 1621539600
|
||||||
|
expected_date = "2021-05-20T19:40:00+00:00"
|
||||||
|
assert date_parser(timestamp) == expected_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_parser_with_str():
|
||||||
|
"""iso timestamp"""
|
||||||
|
date_str = "2021-05-21"
|
||||||
|
expected_date = "2021-05-21T00:00:00+00:00"
|
||||||
|
assert date_parser(date_str) == expected_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_parser_with_invalid_input():
|
||||||
|
"""invalid type"""
|
||||||
|
invalid_input = [1621539600]
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
date_parser(invalid_input)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_parser_with_invalid_string_format():
|
||||||
|
"""invalid date string"""
|
||||||
|
invalid_date_str = "21/05/2021"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
date_parser(invalid_date_str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_parser_with_numeric_string():
|
||||||
|
"""as number"""
|
||||||
|
timestamp = "100"
|
||||||
|
expected_seconds = 100
|
||||||
|
assert time_parser(timestamp) == expected_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_parser_with_hh_mm_ss_format():
|
||||||
|
"""to seconds"""
|
||||||
|
timestamp = "01:00:00"
|
||||||
|
expected_seconds = 3600.0
|
||||||
|
assert time_parser(timestamp) == expected_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_parser_with_empty_string():
|
||||||
|
"""handle empty"""
|
||||||
|
timestamp = ""
|
||||||
|
assert time_parser(timestamp) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_parser_with_invalid_format():
|
||||||
|
"""not enough to unpack"""
|
||||||
|
timestamp = "01:00"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
time_parser(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_parser_with_non_numeric_input():
|
||||||
|
"""non numeric"""
|
||||||
|
timestamp = "1a:00:00"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
time_parser(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_mapping():
|
||||||
|
"""test mappint"""
|
||||||
|
index_config = get_mapping()
|
||||||
|
assert isinstance(index_config, list)
|
||||||
|
assert all(isinstance(i, dict) for i in index_config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_shorts():
|
||||||
|
"""is shorts id"""
|
||||||
|
youtube_id = "YG3-Pw3rixU"
|
||||||
|
assert is_shorts(youtube_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_not_shorts():
|
||||||
|
"""is not shorts id"""
|
||||||
|
youtube_id = "Ogr9kbypSNg"
|
||||||
|
assert is_shorts(youtube_id) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_duration_str():
|
||||||
|
"""only seconds"""
|
||||||
|
assert get_duration_str(None) == "NA"
|
||||||
|
assert get_duration_str(5) == "5s"
|
||||||
|
assert get_duration_str(10) == "10s"
|
||||||
|
assert get_duration_str(500) == "8m 20s"
|
||||||
|
assert get_duration_str(1000) == "16m 40s"
|
||||||
|
assert get_duration_str(5000) == "1h 23m 20s"
|
||||||
|
assert get_duration_str(500000) == "5d 18h 53m 20s"
|
||||||
|
assert get_duration_str(5000000) == "57d 20h 53m 20s"
|
||||||
|
assert get_duration_str(50000000) == "1y 213d 16h 53m 20s"
|
||||||
145
backend/common/tests/test_src/test_urlparser.py
Normal file
145
backend/common/tests/test_src/test_urlparser.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""tests for url parser"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from common.src.urlparser import Parser
|
||||||
|
|
||||||
|
# video id parsing
|
||||||
|
VIDEO_URL_IN = [
|
||||||
|
"7DKv5H5Frt0",
|
||||||
|
"https://www.youtube.com/watch?v=7DKv5H5Frt0",
|
||||||
|
"https://www.youtube.com/watch?v=7DKv5H5Frt0&t=113&feature=shared",
|
||||||
|
"https://www.youtube.com/watch?v=7DKv5H5Frt0&list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5&index=1&pp=iAQB" # noqa: E501
|
||||||
|
"https://youtu.be/7DKv5H5Frt0",
|
||||||
|
"https://www.youtube.com/live/7DKv5H5Frt0",
|
||||||
|
]
|
||||||
|
VIDEO_OUT = [{"type": "video", "url": "7DKv5H5Frt0", "vid_type": "unknown"}]
|
||||||
|
VIDEO_TEST_CASES = [(i, VIDEO_OUT) for i in VIDEO_URL_IN]
|
||||||
|
|
||||||
|
# shorts id parsing
|
||||||
|
SHORTS_URL_IN = [
|
||||||
|
"https://www.youtube.com/shorts/YG3-Pw3rixU",
|
||||||
|
"https://youtube.com/shorts/YG3-Pw3rixU?feature=shared",
|
||||||
|
]
|
||||||
|
SHORTS_OUT = [{"type": "video", "url": "YG3-Pw3rixU", "vid_type": "shorts"}]
|
||||||
|
SHORTS_TEST_CASES = [(i, SHORTS_OUT) for i in SHORTS_URL_IN]
|
||||||
|
|
||||||
|
# channel id parsing
|
||||||
|
CHANNEL_URL_IN = [
|
||||||
|
"UCBa659QWEk1AI4Tg--mrJ2A",
|
||||||
|
"@TomScottGo",
|
||||||
|
"https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A",
|
||||||
|
"https://www.youtube.com/@TomScottGo",
|
||||||
|
]
|
||||||
|
CHANNEL_OUT = [
|
||||||
|
{
|
||||||
|
"type": "channel",
|
||||||
|
"url": "UCBa659QWEk1AI4Tg--mrJ2A",
|
||||||
|
"vid_type": "unknown",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
CHANNEL_TEST_CASES = [(i, CHANNEL_OUT) for i in CHANNEL_URL_IN]
|
||||||
|
|
||||||
|
# channel vid type parsing
|
||||||
|
CHANNEL_VID_TYPES = [
|
||||||
|
(
|
||||||
|
"https://www.youtube.com/@IBRACORP/videos",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "channel",
|
||||||
|
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
|
||||||
|
"vid_type": "videos",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://www.youtube.com/@IBRACORP/shorts",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "channel",
|
||||||
|
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
|
||||||
|
"vid_type": "shorts",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://www.youtube.com/@IBRACORP/streams",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "channel",
|
||||||
|
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
|
||||||
|
"vid_type": "streams",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# playlist id parsing
|
||||||
|
PLAYLIST_URL_IN = [
|
||||||
|
"PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
|
||||||
|
"https://www.youtube.com/playlist?list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
|
||||||
|
]
|
||||||
|
PLAYLIST_OUT = [
|
||||||
|
{
|
||||||
|
"type": "playlist",
|
||||||
|
"url": "PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
|
||||||
|
"vid_type": "unknown",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
PLAYLIST_TEST_CASES = [(i, PLAYLIST_OUT) for i in PLAYLIST_URL_IN]
|
||||||
|
|
||||||
|
# personal playlists
|
||||||
|
EXPECTED_WL = [{"type": "playlist", "url": "WL", "vid_type": "unknown"}]
|
||||||
|
EXPECTED_LL = [{"type": "playlist", "url": "LL", "vid_type": "unknown"}]
|
||||||
|
PERSONAL_PLAYLISTS_TEST_CASES = [
|
||||||
|
("WL", EXPECTED_WL),
|
||||||
|
("https://www.youtube.com/playlist?list=WL", EXPECTED_WL),
|
||||||
|
("LL", EXPECTED_LL),
|
||||||
|
("https://www.youtube.com/playlist?list=LL", EXPECTED_LL),
|
||||||
|
]
|
||||||
|
|
||||||
|
# collect tests expected to pass
|
||||||
|
PASSTING_TESTS = []
|
||||||
|
PASSTING_TESTS.extend(VIDEO_TEST_CASES)
|
||||||
|
PASSTING_TESTS.extend(SHORTS_TEST_CASES)
|
||||||
|
PASSTING_TESTS.extend(CHANNEL_TEST_CASES)
|
||||||
|
PASSTING_TESTS.extend(CHANNEL_VID_TYPES)
|
||||||
|
PASSTING_TESTS.extend(PLAYLIST_TEST_CASES)
|
||||||
|
PASSTING_TESTS.extend(PERSONAL_PLAYLISTS_TEST_CASES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url_str, expected_result", PASSTING_TESTS)
|
||||||
|
def test_passing_parse(url_str, expected_result):
|
||||||
|
"""test parser"""
|
||||||
|
parser = Parser(url_str, use_cache=False)
|
||||||
|
parsed = parser.parse()
|
||||||
|
assert parsed == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
INVALID_IDS_ERRORS = [
|
||||||
|
"aaaaa",
|
||||||
|
"https://www.youtube.com/playlist?list=AAAA",
|
||||||
|
"https://www.youtube.com/channel/UC9-y-6csu5WGm29I7Jiwpn",
|
||||||
|
"https://www.youtube.com/watch?v=CK3_zarXkw",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid_value", INVALID_IDS_ERRORS)
|
||||||
|
def test_invalid_ids(invalid_value):
|
||||||
|
"""test for invalid IDs"""
|
||||||
|
with pytest.raises(ValueError, match="not a valid id_str"):
|
||||||
|
parser = Parser(invalid_value, use_cache=False)
|
||||||
|
parser.parse()
|
||||||
|
|
||||||
|
|
||||||
|
INVALID_DOMAINS = [
|
||||||
|
"https://vimeo.com/32001208",
|
||||||
|
"https://peertube.tv/w/8RiJE2j2nw569FVgPNjDt7",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid_value", INVALID_DOMAINS)
|
||||||
|
def test_invalid_domains(invalid_value):
|
||||||
|
"""raise error on none YT domains"""
|
||||||
|
parser = Parser(invalid_value, use_cache=False)
|
||||||
|
with pytest.raises(ValueError, match="invalid domain"):
|
||||||
|
parser.parse()
|
||||||
33
backend/common/urls.py
Normal file
33
backend/common/urls.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""all api urls"""
|
||||||
|
|
||||||
|
from common import views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("ping/", views.PingView.as_view(), name="ping"),
|
||||||
|
path(
|
||||||
|
"refresh/",
|
||||||
|
views.RefreshView.as_view(),
|
||||||
|
name="api-refresh",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"watched/",
|
||||||
|
views.WatchedView.as_view(),
|
||||||
|
name="api-watched",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"search/",
|
||||||
|
views.SearchView.as_view(),
|
||||||
|
name="api-search",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"notification/",
|
||||||
|
views.NotificationView.as_view(),
|
||||||
|
name="api-notification",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"health/",
|
||||||
|
views.HealthCheck.as_view(),
|
||||||
|
name="api-health",
|
||||||
|
),
|
||||||
|
]
|
||||||
210
backend/common/views.py
Normal file
210
backend/common/views.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""all API views"""
|
||||||
|
|
||||||
|
from appsettings.src.config import ReleaseVersion
|
||||||
|
from appsettings.src.reindex import ReindexProgress
|
||||||
|
from common.serializers import (
|
||||||
|
AsyncTaskResponseSerializer,
|
||||||
|
ErrorResponseSerializer,
|
||||||
|
NotificationQueryFilterSerializer,
|
||||||
|
NotificationSerializer,
|
||||||
|
PingSerializer,
|
||||||
|
RefreshAddDataSerializer,
|
||||||
|
RefreshAddQuerySerializer,
|
||||||
|
RefreshQuerySerializer,
|
||||||
|
RefreshResponseSerializer,
|
||||||
|
WatchedDataSerializer,
|
||||||
|
)
|
||||||
|
from common.src.searching import SearchForm
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from common.src.watched import WatchState
|
||||||
|
from common.views_base import AdminOnly, ApiBaseView
|
||||||
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from task.tasks import check_reindex
|
||||||
|
|
||||||
|
|
||||||
|
class PingView(ApiBaseView):
|
||||||
|
"""resolves to /api/ping/
|
||||||
|
GET: test your connection
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={200: OpenApiResponse(PingSerializer())},
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get pong"""
|
||||||
|
data = {
|
||||||
|
"response": "pong",
|
||||||
|
"user": request.user.id,
|
||||||
|
"version": ReleaseVersion().get_local_version(),
|
||||||
|
"ta_update": ReleaseVersion().get_update(),
|
||||||
|
}
|
||||||
|
serializer = PingSerializer(data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshView(ApiBaseView):
|
||||||
|
"""resolves to /api/refresh/
|
||||||
|
GET: get refresh progress
|
||||||
|
POST: start a manual refresh task
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(RefreshResponseSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
parameters=[RefreshQuerySerializer()],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get refresh status"""
|
||||||
|
query_serializer = RefreshQuerySerializer(data=request.query_params)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
request_type = validated_query.get("type")
|
||||||
|
request_id = validated_query.get("id")
|
||||||
|
|
||||||
|
if request_id and not request_type:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "specified id also needs type"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress = ReindexProgress(
|
||||||
|
request_type=request_type, request_id=request_id
|
||||||
|
).get_progress()
|
||||||
|
except ValueError:
|
||||||
|
error = ErrorResponseSerializer({"error": "bad request"})
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
response_serializer = RefreshResponseSerializer(progress)
|
||||||
|
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=RefreshAddDataSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(AsyncTaskResponseSerializer()),
|
||||||
|
},
|
||||||
|
parameters=[RefreshAddQuerySerializer()],
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""add to reindex queue"""
|
||||||
|
query_serializer = RefreshAddQuerySerializer(data=request.query_params)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
|
||||||
|
data_serializer = RefreshAddDataSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
|
||||||
|
extract_videos = validated_query.get("extract_videos")
|
||||||
|
task = check_reindex.delay(
|
||||||
|
data=validated_data, extract_videos=extract_videos
|
||||||
|
)
|
||||||
|
message = {
|
||||||
|
"message": "reindex task started",
|
||||||
|
"task_id": task.id,
|
||||||
|
}
|
||||||
|
serializer = AsyncTaskResponseSerializer(message)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchedView(ApiBaseView):
|
||||||
|
"""resolves to /api/watched/
|
||||||
|
POST: change watched state of video, channel or playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=WatchedDataSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(WatchedDataSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""change watched state"""
|
||||||
|
data_serializer = WatchedDataSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
youtube_id = validated_data.get("id")
|
||||||
|
is_watched = validated_data.get("is_watched")
|
||||||
|
|
||||||
|
if not youtube_id or is_watched is None:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "missing id or is_watched"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
WatchState(youtube_id, is_watched, request.user.id).change()
|
||||||
|
return Response(data_serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchView(ApiBaseView):
|
||||||
|
"""resolves to /api/search/
|
||||||
|
GET: run a search with the string in the ?query parameter
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(request):
|
||||||
|
"""handle get request
|
||||||
|
search through all indexes"""
|
||||||
|
search_query = request.GET.get("query", None)
|
||||||
|
if search_query is None:
|
||||||
|
return Response(
|
||||||
|
{"message": "no search query specified"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
search_results = SearchForm().multi_search(search_query)
|
||||||
|
return Response(search_results)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationView(ApiBaseView):
|
||||||
|
"""resolves to /api/notification/
|
||||||
|
GET: returns a list of notifications
|
||||||
|
filter query to filter messages by group
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_filters = ["download", "settings", "channel"]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(NotificationSerializer(many=True)),
|
||||||
|
},
|
||||||
|
parameters=[NotificationQueryFilterSerializer],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get all notifications"""
|
||||||
|
query_serializer = NotificationQueryFilterSerializer(
|
||||||
|
data=request.query_params
|
||||||
|
)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
filter_by = validated_query.get("filter")
|
||||||
|
|
||||||
|
query = "message"
|
||||||
|
if filter_by in self.valid_filters:
|
||||||
|
query = f"{query}:{filter_by}"
|
||||||
|
|
||||||
|
notifications = RedisArchivist().list_items(query)
|
||||||
|
response_serializer = NotificationSerializer(notifications, many=True)
|
||||||
|
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheck(APIView):
|
||||||
|
"""health check view, no auth needed"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""health check, no auth needed"""
|
||||||
|
return Response("OK", status=200)
|
||||||
102
backend/common/views_base.py
Normal file
102
backend/common/views_base.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""base classes to inherit from"""
|
||||||
|
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
from common.src.index_generic import Pagination
|
||||||
|
from common.src.search_processor import SearchProcess, process_aggs
|
||||||
|
from rest_framework import permissions
|
||||||
|
from rest_framework.authentication import (
|
||||||
|
SessionAuthentication,
|
||||||
|
TokenAuthentication,
|
||||||
|
)
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
def check_admin(user):
|
||||||
|
"""check for admin permission for restricted views"""
|
||||||
|
return user.is_staff or user.groups.filter(name="admin").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOnly(permissions.BasePermission):
|
||||||
|
"""allow only admin"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return check_admin(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminWriteOnly(permissions.BasePermission):
|
||||||
|
"""allow only admin writes"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in permissions.SAFE_METHODS:
|
||||||
|
return permissions.IsAuthenticated().has_permission(request, view)
|
||||||
|
|
||||||
|
return check_admin(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiBaseView(APIView):
|
||||||
|
"""base view to inherit from"""
|
||||||
|
|
||||||
|
authentication_classes = [SessionAuthentication, TokenAuthentication]
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
search_base = ""
|
||||||
|
data = ""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.response = {}
|
||||||
|
self.data = {"query": {"match_all": {}}}
|
||||||
|
self.status_code = False
|
||||||
|
self.context = False
|
||||||
|
self.pagination_handler = False
|
||||||
|
|
||||||
|
def get_document(self, document_id, progress_match=None):
|
||||||
|
"""get single document from es"""
|
||||||
|
path = f"{self.search_base}{document_id}"
|
||||||
|
response, status_code = ElasticWrap(path).get()
|
||||||
|
try:
|
||||||
|
self.response = SearchProcess(
|
||||||
|
response, match_video_user_progress=progress_match
|
||||||
|
).process()
|
||||||
|
except KeyError:
|
||||||
|
print(f"item not found: {document_id}")
|
||||||
|
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def initiate_pagination(self, request):
|
||||||
|
"""set initial pagination values"""
|
||||||
|
self.pagination_handler = Pagination(request)
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"size": self.pagination_handler.pagination["page_size"],
|
||||||
|
"from": self.pagination_handler.pagination["page_from"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_document_list(self, request, pagination=True, progress_match=None):
|
||||||
|
"""get a list of results"""
|
||||||
|
if pagination:
|
||||||
|
self.initiate_pagination(request)
|
||||||
|
|
||||||
|
es_handler = ElasticWrap(self.search_base)
|
||||||
|
response, status_code = es_handler.get(data=self.data)
|
||||||
|
self.response["data"] = SearchProcess(
|
||||||
|
response, match_video_user_progress=progress_match
|
||||||
|
).process()
|
||||||
|
if self.response["data"]:
|
||||||
|
self.status_code = status_code
|
||||||
|
else:
|
||||||
|
self.status_code = 404
|
||||||
|
|
||||||
|
if pagination and response.get("hits"):
|
||||||
|
self.pagination_handler.validate(
|
||||||
|
response["hits"]["total"]["value"]
|
||||||
|
)
|
||||||
|
self.response["paginate"] = self.pagination_handler.pagination
|
||||||
|
|
||||||
|
def get_aggs(self):
|
||||||
|
"""get aggs alone"""
|
||||||
|
self.data["size"] = 0
|
||||||
|
response, _ = ElasticWrap(self.search_base).get(data=self.data)
|
||||||
|
process_aggs(response)
|
||||||
|
|
||||||
|
self.response = response.get("aggregations")
|
||||||
36
backend/config/management/commands/ta_change_password.py
Normal file
36
backend/config/management/commands/ta_change_password.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""change user password"""
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""change password"""
|
||||||
|
|
||||||
|
help = "Change Password of user"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("username", type=str)
|
||||||
|
parser.add_argument("password", type=str)
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
"""entry point"""
|
||||||
|
username = kwargs["username"]
|
||||||
|
new_password = kwargs["password"]
|
||||||
|
self.stdout.write(f"Changing password for user '{username}'")
|
||||||
|
try:
|
||||||
|
user = User.objects.get(name=username)
|
||||||
|
except User.DoesNotExist as err:
|
||||||
|
message = f"Username '{username}' does not exist. "
|
||||||
|
message += "Available username(s) are:\n"
|
||||||
|
message += ", ".join([i.name for i in User.objects.all()])
|
||||||
|
raise CommandError(message) from err
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ updated password for user '{username}'")
|
||||||
|
)
|
||||||
76
backend/config/management/commands/ta_config_backup.py
Normal file
76
backend/config/management/commands/ta_config_backup.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""backup config for sqlite reset and restore"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from home.models import CustomPeriodicTask
|
||||||
|
from home.src.ta.settings import EnvironmentSettings
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""export"""
|
||||||
|
|
||||||
|
help = "Exports all users and their auth tokens to a JSON file"
|
||||||
|
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
"""entry point"""
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"user_data": self.get_users(),
|
||||||
|
"schedule_data": self.get_schedules(),
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.FILE, "w", encoding="utf-8") as json_file:
|
||||||
|
json_file.write(json.dumps(data))
|
||||||
|
|
||||||
|
def get_users(self):
|
||||||
|
"""get users"""
|
||||||
|
|
||||||
|
users = User.objects.all()
|
||||||
|
|
||||||
|
user_data = []
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
user_info = {
|
||||||
|
"username": user.name,
|
||||||
|
"is_staff": user.is_staff,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"password": user.password,
|
||||||
|
"tokens": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = Token.objects.get(user=user)
|
||||||
|
user_info["tokens"] = [token.key]
|
||||||
|
except Token.DoesNotExist:
|
||||||
|
user_info["tokens"] = []
|
||||||
|
|
||||||
|
user_data.append(user_info)
|
||||||
|
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
def get_schedules(self):
|
||||||
|
"""get schedules"""
|
||||||
|
|
||||||
|
all_schedules = CustomPeriodicTask.objects.all()
|
||||||
|
schedule_data = []
|
||||||
|
|
||||||
|
for schedule in all_schedules:
|
||||||
|
schedule_info = {
|
||||||
|
"name": schedule.name,
|
||||||
|
"crontab": {
|
||||||
|
"minute": schedule.crontab.minute,
|
||||||
|
"hour": schedule.crontab.hour,
|
||||||
|
"day_of_week": schedule.crontab.day_of_week,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule_data.append(schedule_info)
|
||||||
|
|
||||||
|
return schedule_data
|
||||||
89
backend/config/management/commands/ta_config_restore.py
Normal file
89
backend/config/management/commands/ta_config_restore.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""restore config from backup"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django_celery_beat.models import CrontabSchedule
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from task.models import CustomPeriodicTask
|
||||||
|
from task.src.task_config import TASK_CONFIG
|
||||||
|
from user.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""export"""
|
||||||
|
|
||||||
|
help = "Exports all users and their auth tokens to a JSON file"
|
||||||
|
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""handle"""
|
||||||
|
self.stdout.write("restore users and schedules")
|
||||||
|
data = self.get_config()
|
||||||
|
self.restore_users(data["user_data"])
|
||||||
|
self.restore_schedules(data["schedule_data"])
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
" ✓ restore completed. Please restart the container."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_config(self) -> dict:
|
||||||
|
"""get config from backup"""
|
||||||
|
with open(self.FILE, "r", encoding="utf-8") as json_file:
|
||||||
|
data = json.loads(json_file.read())
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ json file found: {self.FILE}")
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def restore_users(self, user_data: list[dict]) -> None:
|
||||||
|
"""restore users from config"""
|
||||||
|
self.stdout.write("delete existing users")
|
||||||
|
Account.objects.all().delete()
|
||||||
|
|
||||||
|
self.stdout.write("recreate users")
|
||||||
|
for user_info in user_data:
|
||||||
|
user = Account.objects.create(
|
||||||
|
name=user_info["username"],
|
||||||
|
is_staff=user_info["is_staff"],
|
||||||
|
is_superuser=user_info["is_superuser"],
|
||||||
|
password=user_info["password"],
|
||||||
|
)
|
||||||
|
for token in user_info["tokens"]:
|
||||||
|
Token.objects.create(user=user, key=token)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ recreated user with name: {user_info['username']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def restore_schedules(self, schedule_data: list[dict]) -> None:
|
||||||
|
"""restore schedules"""
|
||||||
|
self.stdout.write("delete existing schedules")
|
||||||
|
CustomPeriodicTask.objects.all().delete()
|
||||||
|
|
||||||
|
self.stdout.write("recreate schedules")
|
||||||
|
for schedule in schedule_data:
|
||||||
|
task_name = schedule["name"]
|
||||||
|
description = TASK_CONFIG[task_name].get("title")
|
||||||
|
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||||
|
minute=schedule["crontab"]["minute"],
|
||||||
|
hour=schedule["crontab"]["hour"],
|
||||||
|
day_of_week=schedule["crontab"]["day_of_week"],
|
||||||
|
timezone=EnvironmentSettings.TZ,
|
||||||
|
)
|
||||||
|
task = CustomPeriodicTask.objects.create(
|
||||||
|
name=task_name,
|
||||||
|
task=task_name,
|
||||||
|
description=description,
|
||||||
|
crontab=crontab,
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ recreated schedule: {task}")
|
||||||
|
)
|
||||||
@@ -6,10 +6,10 @@ Functionality:
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
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 = """
|
TOPIC = """
|
||||||
|
|
||||||
@@ -58,7 +58,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
message = " 🗙 Redis connection failed"
|
message = " 🗙 Redis connection failed"
|
||||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
RedisArchivist().exec("PING")
|
try:
|
||||||
|
redis_conn.execute_command("PING")
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
message = f" 🗙 {type(err).__name__}: {err}"
|
||||||
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
|
|
||||||
sleep(60)
|
sleep(60)
|
||||||
raise CommandError(message)
|
raise CommandError(message)
|
||||||
|
|
||||||
@@ -89,8 +94,17 @@ class Command(BaseCommand):
|
|||||||
sleep(5)
|
sleep(5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if status_code and status_code == 401:
|
||||||
|
sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
if status_code and status_code == 200:
|
if status_code and status_code == 200:
|
||||||
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
|
path = (
|
||||||
|
"_cluster/health?"
|
||||||
|
"wait_for_status=yellow&"
|
||||||
|
"timeout=60s&"
|
||||||
|
"wait_for_active_shards=1"
|
||||||
|
)
|
||||||
_, _ = ElasticWrap(path).get(timeout=60)
|
_, _ = ElasticWrap(path).get(timeout=60)
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(" ✓ ES connection established")
|
self.style.SUCCESS(" ✓ ES connection established")
|
||||||
@@ -8,10 +8,11 @@ Functionality:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from home.models import Account
|
from user.models import Account
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
|
||||||
|
|
||||||
LOGO = """
|
LOGO = """
|
||||||
|
|
||||||
@@ -60,9 +61,14 @@ EXPECTED_ENV_VARS = [
|
|||||||
"ES_URL",
|
"ES_URL",
|
||||||
"TA_HOST",
|
"TA_HOST",
|
||||||
]
|
]
|
||||||
|
UNEXPECTED_ENV_VARS = {
|
||||||
|
"TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'",
|
||||||
|
"REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string",
|
||||||
|
"REDIS_PORT": "Has been consolidated in 'REDIS_CON' connection string",
|
||||||
|
"ENABLE_CAST": "That is now a toggle in setting and DISABLE_STATIC_AUTH",
|
||||||
|
}
|
||||||
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
|
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
|
||||||
NGINX = "/etc/nginx/sites-available/default"
|
NGINX = "/etc/nginx/sites-available/default"
|
||||||
UWSGI = "/app/uwsgi.ini"
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -76,10 +82,11 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(LOGO)
|
self.stdout.write(LOGO)
|
||||||
self.stdout.write(TOPIC)
|
self.stdout.write(TOPIC)
|
||||||
self._expected_vars()
|
self._expected_vars()
|
||||||
|
self._unexpected_vars()
|
||||||
self._elastic_user_overwrite()
|
self._elastic_user_overwrite()
|
||||||
self._ta_port_overwrite()
|
self._ta_port_overwrite()
|
||||||
self._ta_uwsgi_overwrite()
|
self._ta_backend_port_overwrite()
|
||||||
self._enable_cast_overwrite()
|
self._disable_static_auth()
|
||||||
self._create_superuser()
|
self._create_superuser()
|
||||||
|
|
||||||
def _expected_vars(self):
|
def _expected_vars(self):
|
||||||
@@ -90,20 +97,41 @@ class Command(BaseCommand):
|
|||||||
if not env.get(var):
|
if not env.get(var):
|
||||||
message = f" 🗙 expected env var {var} not set\n {INST}"
|
message = f" 🗙 expected env var {var} not set\n {INST}"
|
||||||
self.stdout.write(self.style.ERROR(message))
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
sleep(60)
|
||||||
raise CommandError(message)
|
raise CommandError(message)
|
||||||
|
|
||||||
message = " ✓ all expected env vars are set"
|
message = " ✓ all expected env vars are set"
|
||||||
self.stdout.write(self.style.SUCCESS(message))
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
def _unexpected_vars(self):
|
||||||
|
"""check for unexpected env vars"""
|
||||||
|
self.stdout.write("[2] checking for unexpected env vars")
|
||||||
|
for var, message in UNEXPECTED_ENV_VARS.items():
|
||||||
|
if not os.environ.get(var):
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f" 🗙 unexpected env var {var} found\n"
|
||||||
|
f" {message} \n"
|
||||||
|
" see release notes for a list of all changes."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
message = " ✓ no unexpected env vars found"
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
def _elastic_user_overwrite(self):
|
def _elastic_user_overwrite(self):
|
||||||
"""check for ELASTIC_USER overwrite"""
|
"""check for ELASTIC_USER overwrite"""
|
||||||
self.stdout.write("[2] check ES user overwrite")
|
self.stdout.write("[3] check ES user overwrite")
|
||||||
env = EnvironmentSettings.ES_USER
|
env = EnvironmentSettings.ES_USER
|
||||||
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
|
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
|
||||||
|
|
||||||
def _ta_port_overwrite(self):
|
def _ta_port_overwrite(self):
|
||||||
"""set TA_PORT overwrite for nginx"""
|
"""set TA_PORT overwrite for nginx"""
|
||||||
self.stdout.write("[3] check TA_PORT overwrite")
|
self.stdout.write("[4] check TA_PORT overwrite")
|
||||||
overwrite = EnvironmentSettings.TA_PORT
|
overwrite = EnvironmentSettings.TA_PORT
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
|
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
|
||||||
@@ -119,52 +147,49 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(message))
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
def _ta_uwsgi_overwrite(self):
|
def _ta_backend_port_overwrite(self):
|
||||||
"""set TA_UWSGI_PORT overwrite"""
|
"""set TA_BACKEND_PORT overwrite"""
|
||||||
self.stdout.write("[4] check TA_UWSGI_PORT overwrite")
|
self.stdout.write("[5] check TA_BACKEND_PORT overwrite")
|
||||||
overwrite = EnvironmentSettings.TA_UWSGI_PORT
|
overwrite = EnvironmentSettings.TA_BACKEND_PORT
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
message = " TA_UWSGI_PORT is not set"
|
message = " TA_BACKEND_PORT is not set"
|
||||||
self.stdout.write(self.style.SUCCESS(message))
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
return
|
return
|
||||||
|
|
||||||
# nginx
|
# modify nginx conf
|
||||||
regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}")
|
regex = re.compile(r"proxy_pass http://localhost:[0-9]{1,5}")
|
||||||
to_overwrite = f"uwsgi_pass localhost:{overwrite}"
|
to_overwrite = f"proxy_pass http://localhost:{overwrite}"
|
||||||
changed = file_overwrite(NGINX, regex, to_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:
|
if changed:
|
||||||
message = f" ✓ TA_UWSGI_PORT changed to {overwrite}"
|
message = f" ✓ TA_BACKEND_PORT changed to {overwrite}"
|
||||||
else:
|
else:
|
||||||
message = f" ✓ TA_UWSGI_PORT already set to {overwrite}"
|
message = f" ✓ TA_BACKEND_PORT already set to {overwrite}"
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(message))
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
def _enable_cast_overwrite(self):
|
def _disable_static_auth(self):
|
||||||
"""cast workaround, remove auth for static files in nginx"""
|
"""cast workaround, remove auth for static files in nginx"""
|
||||||
self.stdout.write("[5] check ENABLE_CAST overwrite")
|
self.stdout.write("[7] check DISABLE_STATIC_AUTH overwrite")
|
||||||
overwrite = EnvironmentSettings.ENABLE_CAST
|
overwrite = EnvironmentSettings.DISABLE_STATIC_AUTH
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" DISABLE_STATIC_AUTH is not set")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n")
|
regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n")
|
||||||
changed = file_overwrite(NGINX, regex, "")
|
changed = file_overwrite(NGINX, regex, "")
|
||||||
if changed:
|
if changed:
|
||||||
message = " ✓ process nginx to enable Cast"
|
message = " ✓ process nginx to disable static auth"
|
||||||
else:
|
else:
|
||||||
message = " ✓ Cast is already enabled in nginx"
|
message = " ✓ static auth is already disabled in nginx"
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(message))
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
def _create_superuser(self):
|
def _create_superuser(self):
|
||||||
"""create superuser if not exist"""
|
"""create superuser if not exist"""
|
||||||
self.stdout.write("[6] create superuser")
|
self.stdout.write("[8] create superuser")
|
||||||
is_created = Account.objects.filter(is_superuser=True)
|
is_created = Account.objects.filter(is_superuser=True)
|
||||||
if is_created:
|
if is_created:
|
||||||
message = " superuser already created"
|
message = " superuser already created"
|
||||||
391
backend/config/management/commands/ta_startup.py
Normal file
391
backend/config/management/commands/ta_startup.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- Application startup
|
||||||
|
- Apply migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from random import randint
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from appsettings.src.config import AppConfig, ReleaseVersion
|
||||||
|
from appsettings.src.index_setup import ElasitIndexWrap
|
||||||
|
from appsettings.src.snapshot import ElasticSnapshot
|
||||||
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
from common.src.helper import clear_dl_cache
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import dateformat
|
||||||
|
from django_celery_beat.models import CrontabSchedule, PeriodicTasks
|
||||||
|
from redis.exceptions import ResponseError
|
||||||
|
from task.models import CustomPeriodicTask
|
||||||
|
from task.src.config_schedule import ScheduleBuilder
|
||||||
|
from task.src.task_manager import TaskManager
|
||||||
|
from task.tasks import version_check
|
||||||
|
|
||||||
|
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._make_folders()
|
||||||
|
self._clear_redis_keys()
|
||||||
|
self._clear_tasks()
|
||||||
|
self._clear_dl_cache()
|
||||||
|
self._version_check()
|
||||||
|
self._index_setup()
|
||||||
|
self._snapshot_check()
|
||||||
|
self._mig_app_settings()
|
||||||
|
self._create_default_schedules()
|
||||||
|
self._update_schedule_tz()
|
||||||
|
self._init_app_config()
|
||||||
|
self._mig_channel_tags()
|
||||||
|
self._mig_video_channel_tags()
|
||||||
|
self._mig_fix_download_channel_indexed()
|
||||||
|
|
||||||
|
def _make_folders(self):
|
||||||
|
"""make expected cache folders"""
|
||||||
|
self.stdout.write("[1] 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("[2] 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("[3] 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("[4] 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("[5] 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"))
|
||||||
|
|
||||||
|
version_task = CustomPeriodicTask.objects.filter(name="version_check")
|
||||||
|
if not version_task.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not version_task.first().last_run_at:
|
||||||
|
self.style.SUCCESS(" ✓ send initial version check task")
|
||||||
|
version_check.delay()
|
||||||
|
|
||||||
|
def _index_setup(self):
|
||||||
|
"""migration: validate index mappings"""
|
||||||
|
self.stdout.write("[6] validate index mappings")
|
||||||
|
ElasitIndexWrap().setup()
|
||||||
|
|
||||||
|
def _snapshot_check(self):
|
||||||
|
"""migration setup snapshots"""
|
||||||
|
self.stdout.write("[7] setup snapshots")
|
||||||
|
ElasticSnapshot().setup()
|
||||||
|
|
||||||
|
def _mig_app_settings(self) -> None:
|
||||||
|
"""update from v0.4.13 to v0.5.0, migrate application settings"""
|
||||||
|
self.stdout.write("[MIGRATION] move appconfig to ES")
|
||||||
|
try:
|
||||||
|
config = RedisArchivist().get_message("config")
|
||||||
|
except ResponseError:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" Redis does not support JSON decoding")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not config or config == {"status": False}:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no config values to migrate")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = "ta_config/_doc/appsettings"
|
||||||
|
response, status_code = ElasticWrap(path).post(config)
|
||||||
|
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ migrated appconfig to ES")
|
||||||
|
)
|
||||||
|
RedisArchivist().del_message("config", save=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to migrate app config"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _create_default_schedules(self) -> None:
|
||||||
|
"""create default schedules for new installations"""
|
||||||
|
self.stdout.write("[8] create initial schedules")
|
||||||
|
init_has_run = CustomPeriodicTask.objects.filter(
|
||||||
|
name="version_check"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if init_has_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
" schedule init already done, skipping..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = ScheduleBuilder()
|
||||||
|
check_reindex = builder.get_set_task(
|
||||||
|
"check_reindex", schedule=builder.SCHEDULES["check_reindex"]
|
||||||
|
)
|
||||||
|
check_reindex.task_config.update({"days": 90})
|
||||||
|
check_reindex.last_run_at = dateformat.make_aware(datetime.now())
|
||||||
|
check_reindex.save()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ created new default schedule: {check_reindex}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
thumbnail_check = builder.get_set_task(
|
||||||
|
"thumbnail_check", schedule=builder.SCHEDULES["thumbnail_check"]
|
||||||
|
)
|
||||||
|
thumbnail_check.last_run_at = dateformat.make_aware(datetime.now())
|
||||||
|
thumbnail_check.save()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ created new default schedule: {thumbnail_check}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
daily_random = f"{randint(0, 59)} {randint(0, 23)} *"
|
||||||
|
version_check_task = builder.get_set_task(
|
||||||
|
"version_check", schedule=daily_random
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ created new default schedule: {version_check_task}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ all default schedules created")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_schedule_tz(self) -> None:
|
||||||
|
"""update timezone for Schedule instances"""
|
||||||
|
self.stdout.write("[9] validate schedules TZ")
|
||||||
|
tz = EnvironmentSettings.TZ
|
||||||
|
to_update = CrontabSchedule.objects.exclude(timezone=tz)
|
||||||
|
|
||||||
|
if not to_update.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" all schedules have correct TZ")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = to_update.update(timezone=tz)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ updated {updated} schedules to {tz}.")
|
||||||
|
)
|
||||||
|
PeriodicTasks.update_changed()
|
||||||
|
|
||||||
|
def _init_app_config(self) -> None:
|
||||||
|
"""init default app config to ES"""
|
||||||
|
self.stdout.write("[10] Check AppConfig")
|
||||||
|
response, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" skip completed appsettings init")
|
||||||
|
)
|
||||||
|
updated_defaults = AppConfig().add_new_defaults()
|
||||||
|
for new_default in updated_defaults:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" added new default: {new_default}")
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if status_code != 404:
|
||||||
|
message = " 🗙 ta_config index lookup failed"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
handler = AppConfig.__new__(AppConfig)
|
||||||
|
_, status_code = handler.sync_defaults()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ Created default appsettings.")
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" Status code: {status_code}")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mig_channel_tags(self) -> None:
|
||||||
|
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
|
||||||
|
self.stdout.write("[MIGRATION] fix incorrect channel tags types")
|
||||||
|
path = "ta_channel/_update_by_query"
|
||||||
|
data = {
|
||||||
|
"query": {"match": {"channel_tags": False}},
|
||||||
|
"script": {
|
||||||
|
"source": "ctx._source.channel_tags = []",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ fixed {updated} channel tags")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no channel tags needed fixing")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to fix channel tags"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _mig_video_channel_tags(self) -> None:
|
||||||
|
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
|
||||||
|
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
|
||||||
|
path = "ta_video/_update_by_query"
|
||||||
|
data = {
|
||||||
|
"query": {"match": {"channel.channel_tags": False}},
|
||||||
|
"script": {
|
||||||
|
"source": "ctx._source.channel.channel_tags = []",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ fixed {updated} video channel tags"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
" no video channel tags needed fixing"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to fix video channel tags"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _mig_fix_download_channel_indexed(self) -> None:
|
||||||
|
"""migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
|
||||||
|
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
|
||||||
|
path = "ta_download/_update_by_query"
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must_not": [{"exists": {"field": "channel_indexed"}}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"source": "ctx._source.channel_indexed = false",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ fixed {updated} queued videos")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no queued videos to fix")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to fix video channel tags"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
40
backend/config/management/commands/ta_stop_on_error.py
Normal file
40
backend/config/management/commands/ta_stop_on_error.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""stop on unexpected table"""
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
ERROR_MESSAGE = """
|
||||||
|
🗙 Database is incompatible, see latest release notes for instructions:
|
||||||
|
🗙 https://github.com/tubearchivist/tubearchivist/releases/tag/v0.5.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command framework"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""handle"""
|
||||||
|
self.stdout.write("[MIGRATION] Confirming v0.5.0 table layout")
|
||||||
|
all_tables = self.list_tables()
|
||||||
|
for table in all_tables:
|
||||||
|
if table == "home_account":
|
||||||
|
|
||||||
|
self.stdout.write(self.style.ERROR(ERROR_MESSAGE))
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(ERROR_MESSAGE)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(" ✓ local DB is up-to-date."))
|
||||||
|
|
||||||
|
def list_tables(self):
|
||||||
|
"""raw list all tables"""
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table';"
|
||||||
|
)
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
return [table[0] for table in tables]
|
||||||
@@ -14,11 +14,17 @@ import hashlib
|
|||||||
from os import environ, path
|
from os import environ, path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ldap
|
from common.src.env_settings import EnvironmentSettings
|
||||||
|
from common.src.helper import ta_host_parser
|
||||||
from corsheaders.defaults import default_headers
|
from corsheaders.defaults import default_headers
|
||||||
from django_auth_ldap.config import LDAPSearch
|
|
||||||
from home.src.ta.helper import ta_host_parser
|
try:
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(".env")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -33,12 +39,15 @@ SECRET_KEY = PW_HASH.hexdigest()
|
|||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = bool(environ.get("DJANGO_DEBUG"))
|
DEBUG = bool(environ.get("DJANGO_DEBUG"))
|
||||||
|
|
||||||
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(environ["TA_HOST"])
|
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(
|
||||||
|
environ.get("TA_HOST", "localhost")
|
||||||
|
)
|
||||||
|
CORS_ALLOWED_ORIGINS = CSRF_TRUSTED_ORIGINS
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"home.apps.HomeConfig",
|
"django_celery_beat",
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
@@ -50,7 +59,16 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.humanize",
|
"django.contrib.humanize",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"api",
|
"drf_spectacular",
|
||||||
|
"common",
|
||||||
|
"video",
|
||||||
|
"channel",
|
||||||
|
"playlist",
|
||||||
|
"download",
|
||||||
|
"task",
|
||||||
|
"appsettings",
|
||||||
|
"stats",
|
||||||
|
"user",
|
||||||
"config",
|
"config",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -64,7 +82,6 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"home.src.ta.health.HealthCheckMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
@@ -89,6 +106,9 @@ WSGI_APPLICATION = "config.wsgi.application"
|
|||||||
|
|
||||||
if bool(environ.get("TA_LDAP")):
|
if bool(environ.get("TA_LDAP")):
|
||||||
# pylint: disable=global-at-module-level
|
# pylint: disable=global-at-module-level
|
||||||
|
import ldap
|
||||||
|
from django_auth_ldap.config import LDAPSearch
|
||||||
|
|
||||||
global AUTH_LDAP_SERVER_URI
|
global AUTH_LDAP_SERVER_URI
|
||||||
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
|
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
|
||||||
|
|
||||||
@@ -175,7 +195,6 @@ if bool(environ.get("TA_LDAP")):
|
|||||||
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
|
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
|
||||||
}
|
}
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||||
@@ -208,7 +227,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = "home.Account"
|
AUTH_USER_MODEL = "user.Account"
|
||||||
|
|
||||||
# Forward-auth authentication
|
# Forward-auth authentication
|
||||||
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
||||||
@@ -217,12 +236,35 @@ if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
|||||||
)
|
)
|
||||||
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
|
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
|
||||||
|
|
||||||
MIDDLEWARE.append("home.src.ta.auth.HttpRemoteUserMiddleware")
|
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
|
||||||
|
|
||||||
|
|
||||||
|
# Configure Authentication Backend Combinations
|
||||||
|
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
|
||||||
|
if _login_auth_mode == "local":
|
||||||
|
AUTHENTICATION_BACKENDS: tuple = (
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
)
|
||||||
|
elif _login_auth_mode == "ldap":
|
||||||
|
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
||||||
|
elif _login_auth_mode == "forwardauth":
|
||||||
AUTHENTICATION_BACKENDS = (
|
AUTHENTICATION_BACKENDS = (
|
||||||
"django.contrib.auth.backends.RemoteUserBackend",
|
"django.contrib.auth.backends.RemoteUserBackend",
|
||||||
)
|
)
|
||||||
|
elif _login_auth_mode == "ldap_local":
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"django_auth_ldap.backend.LDAPBackend",
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
|
||||||
|
# means the ModelBackend should be used by default
|
||||||
|
if bool(environ.get("TA_LDAP")):
|
||||||
|
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
||||||
|
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"django.contrib.auth.backends.RemoteUserBackend",
|
||||||
|
)
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||||
@@ -240,7 +282,11 @@ USE_TZ = True
|
|||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATICFILES_DIRS = (str(BASE_DIR.joinpath("static")),)
|
STATICFILES_DIRS = (str(BASE_DIR.joinpath("static")),)
|
||||||
STATIC_ROOT = str(BASE_DIR.joinpath("staticfiles"))
|
STATIC_ROOT = str(BASE_DIR.joinpath("staticfiles"))
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||||
@@ -254,13 +300,14 @@ LOGOUT_REDIRECT_URL = "/login/"
|
|||||||
# background.js makes the request so HTTP_ORIGIN will be from extension
|
# background.js makes the request so HTTP_ORIGIN will be from extension
|
||||||
if environ.get("DISABLE_CORS"):
|
if environ.get("DISABLE_CORS"):
|
||||||
# disable cors
|
# disable cors
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
else:
|
else:
|
||||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||||
r"moz-extension://*",
|
r"moz-extension://*",
|
||||||
r"chrome-extension://*",
|
r"chrome-extension://*",
|
||||||
]
|
]
|
||||||
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
|
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
|
||||||
CORS_ALLOW_HEADERS = list(default_headers) + [
|
CORS_ALLOW_HEADERS = list(default_headers) + [
|
||||||
@@ -269,4 +316,16 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
|||||||
|
|
||||||
# TA application settings
|
# TA application settings
|
||||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||||
TA_VERSION = "v0.4.7"
|
TA_VERSION = "v0.5.3"
|
||||||
|
|
||||||
|
# API
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
|
}
|
||||||
|
|
||||||
|
SPECTACULAR_SETTINGS = {
|
||||||
|
"TITLE": "Tube Archivist API",
|
||||||
|
"DESCRIPTION": "API documentation for Tube Archivist backend.",
|
||||||
|
"VERSION": TA_VERSION,
|
||||||
|
"SERVE_INCLUDE_SCHEMA": False,
|
||||||
|
}
|
||||||
@@ -16,9 +16,23 @@ Including another URLconf
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include("home.urls")),
|
path("api/", include("common.urls")),
|
||||||
path("api/", include("api.urls")),
|
path("api/video/", include("video.urls")),
|
||||||
|
path("api/channel/", include("channel.urls")),
|
||||||
|
path("api/playlist/", include("playlist.urls")),
|
||||||
|
path("api/download/", include("download.urls")),
|
||||||
|
path("api/task/", include("task.urls")),
|
||||||
|
path("api/appsettings/", include("appsettings.urls")),
|
||||||
|
path("api/stats/", include("stats.urls")),
|
||||||
|
path("api/user/", include("user.urls")),
|
||||||
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||||
|
path(
|
||||||
|
"api/docs/",
|
||||||
|
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||||
|
name="swagger-ui",
|
||||||
|
),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
]
|
]
|
||||||
95
backend/download/serializers.py
Normal file
95
backend/download/serializers.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""download serializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
|
||||||
|
from rest_framework import serializers
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadItemSerializer(serializers.Serializer):
|
||||||
|
"""serialize download item"""
|
||||||
|
|
||||||
|
auto_start = serializers.BooleanField()
|
||||||
|
channel_id = serializers.CharField()
|
||||||
|
channel_indexed = serializers.BooleanField()
|
||||||
|
channel_name = serializers.CharField()
|
||||||
|
duration = serializers.CharField()
|
||||||
|
published = serializers.CharField()
|
||||||
|
status = serializers.ChoiceField(choices=["pending", "ignore"])
|
||||||
|
timestamp = serializers.IntegerField()
|
||||||
|
title = serializers.CharField()
|
||||||
|
vid_thumb_url = serializers.CharField()
|
||||||
|
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
|
||||||
|
youtube_id = serializers.CharField()
|
||||||
|
message = serializers.CharField(required=False)
|
||||||
|
_index = serializers.CharField(required=False)
|
||||||
|
_score = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadListSerializer(serializers.Serializer):
|
||||||
|
"""serialize download list"""
|
||||||
|
|
||||||
|
data = DownloadItemSerializer(many=True)
|
||||||
|
paginate = PaginationSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadListQuerySerializer(
|
||||||
|
ValidateUnknownFieldsMixin, serializers.Serializer
|
||||||
|
):
|
||||||
|
"""serialize query params for download list"""
|
||||||
|
|
||||||
|
filter = serializers.ChoiceField(
|
||||||
|
choices=["pending", "ignore"], required=False
|
||||||
|
)
|
||||||
|
channel = serializers.CharField(required=False, help_text="channel ID")
|
||||||
|
page = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize bulk delete download queue query string"""
|
||||||
|
|
||||||
|
filter = serializers.ChoiceField(choices=["pending", "ignore"])
|
||||||
|
|
||||||
|
|
||||||
|
class AddDownloadItemSerializer(serializers.Serializer):
|
||||||
|
"""serialize single item to add"""
|
||||||
|
|
||||||
|
youtube_id = serializers.CharField()
|
||||||
|
status = serializers.ChoiceField(choices=["pending", "ignore-force"])
|
||||||
|
|
||||||
|
|
||||||
|
class AddToDownloadListSerializer(serializers.Serializer):
|
||||||
|
"""serialize add to download queue data"""
|
||||||
|
|
||||||
|
data = AddDownloadItemSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AddToDownloadQuerySerializer(serializers.Serializer):
|
||||||
|
"""add to queue query serializer"""
|
||||||
|
|
||||||
|
autostart = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueItemUpdateSerializer(serializers.Serializer):
|
||||||
|
"""update single download queue item"""
|
||||||
|
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=["pending", "ignore", "ignore-force", "priority"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadAggBucketSerializer(serializers.Serializer):
|
||||||
|
"""serialize bucket"""
|
||||||
|
|
||||||
|
key = serializers.ListField(child=serializers.CharField())
|
||||||
|
key_as_string = serializers.CharField()
|
||||||
|
doc_count = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadAggsSerializer(serializers.Serializer):
|
||||||
|
"""serialize download channel bucket aggregations"""
|
||||||
|
|
||||||
|
doc_count_error_upper_bound = serializers.IntegerField()
|
||||||
|
sum_other_doc_count = serializers.IntegerField()
|
||||||
|
buckets = DownloadAggBucketSerializer(many=True)
|
||||||
@@ -4,20 +4,16 @@ Functionality:
|
|||||||
- linked with ta_dowload index
|
- linked with ta_dowload index
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.download.subscriptions import (
|
from appsettings.src.config import AppConfig
|
||||||
ChannelSubscription,
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
PlaylistSubscription,
|
from common.src.helper import get_duration_str, is_shorts, rand_sleep
|
||||||
)
|
from download.src.subscriptions import ChannelSubscription
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from download.src.thumbnails import ThumbManager
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from playlist.src.index import YoutubePlaylist
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from video.src.constants import VideoTypeEnum
|
||||||
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:
|
class PendingIndex:
|
||||||
@@ -171,14 +167,14 @@ class PendingList(PendingIndex):
|
|||||||
self.to_skip = False
|
self.to_skip = False
|
||||||
self.missing_videos = False
|
self.missing_videos = False
|
||||||
|
|
||||||
def parse_url_list(self):
|
def parse_url_list(self, auto_start=False):
|
||||||
"""extract youtube ids from list"""
|
"""extract youtube ids from list"""
|
||||||
self.missing_videos = []
|
self.missing_videos = []
|
||||||
self.get_download()
|
self.get_download()
|
||||||
self.get_indexed()
|
self.get_indexed()
|
||||||
total = len(self.youtube_ids)
|
total = len(self.youtube_ids)
|
||||||
for idx, entry in enumerate(self.youtube_ids):
|
for idx, entry in enumerate(self.youtube_ids):
|
||||||
self._process_entry(entry)
|
self._process_entry(entry, auto_start=auto_start)
|
||||||
if not self.task:
|
if not self.task:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -187,16 +183,15 @@ class PendingList(PendingIndex):
|
|||||||
progress=(idx + 1) / total,
|
progress=(idx + 1) / total,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_entry(self, entry):
|
def _process_entry(self, entry, auto_start=False):
|
||||||
"""process single entry from url list"""
|
"""process single entry from url list"""
|
||||||
vid_type = self._get_vid_type(entry)
|
vid_type = self._get_vid_type(entry)
|
||||||
if entry["type"] == "video":
|
if entry["type"] == "video":
|
||||||
self._add_video(entry["url"], vid_type)
|
self._add_video(entry["url"], vid_type, auto_start=auto_start)
|
||||||
elif entry["type"] == "channel":
|
elif entry["type"] == "channel":
|
||||||
self._parse_channel(entry["url"], vid_type)
|
self._parse_channel(entry["url"], vid_type)
|
||||||
elif entry["type"] == "playlist":
|
elif entry["type"] == "playlist":
|
||||||
self._parse_playlist(entry["url"])
|
self._parse_playlist(entry["url"])
|
||||||
PlaylistSubscription().process_url_str([entry], subscribed=False)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"invalid url_type: {entry}")
|
raise ValueError(f"invalid url_type: {entry}")
|
||||||
|
|
||||||
@@ -209,8 +204,14 @@ class PendingList(PendingIndex):
|
|||||||
|
|
||||||
return VideoTypeEnum(vid_type_str)
|
return VideoTypeEnum(vid_type_str)
|
||||||
|
|
||||||
def _add_video(self, url, vid_type):
|
def _add_video(self, url, vid_type, auto_start=False):
|
||||||
"""add video to list"""
|
"""add video to list"""
|
||||||
|
if auto_start and url in set(
|
||||||
|
i["youtube_id"] for i in self.all_pending
|
||||||
|
):
|
||||||
|
PendingInteract(youtube_id=url, status="priority").update_status()
|
||||||
|
return
|
||||||
|
|
||||||
if url not in self.missing_videos and url not in self.to_skip:
|
if url not in self.missing_videos and url not in self.to_skip:
|
||||||
self.missing_videos.append((url, vid_type))
|
self.missing_videos.append((url, vid_type))
|
||||||
else:
|
else:
|
||||||
@@ -227,24 +228,27 @@ class PendingList(PendingIndex):
|
|||||||
def _parse_playlist(self, url):
|
def _parse_playlist(self, url):
|
||||||
"""add all videos of playlist to list"""
|
"""add all videos of playlist to list"""
|
||||||
playlist = YoutubePlaylist(url)
|
playlist = YoutubePlaylist(url)
|
||||||
playlist.build_json()
|
is_active = playlist.update_playlist()
|
||||||
if not playlist.json_data:
|
if not is_active:
|
||||||
message = f"{playlist.youtube_id}: failed to extract metadata"
|
message = f"{playlist.youtube_id}: failed to extract metadata"
|
||||||
print(message)
|
print(message)
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
video_results = playlist.json_data.get("playlist_entries")
|
entries = playlist.json_data["playlist_entries"]
|
||||||
youtube_ids = [i["youtube_id"] for i in video_results]
|
to_add = [i["youtube_id"] for i in entries if not i["downloaded"]]
|
||||||
for video_id in youtube_ids:
|
if not to_add:
|
||||||
|
return
|
||||||
|
|
||||||
|
for video_id in to_add:
|
||||||
# match vid_type later
|
# match vid_type later
|
||||||
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
|
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
|
||||||
|
|
||||||
def add_to_pending(self, status="pending", auto_start=False):
|
def add_to_pending(self, status="pending", auto_start=False):
|
||||||
"""add missing videos to pending list"""
|
"""add missing videos to pending list"""
|
||||||
self.get_channels()
|
self.get_channels()
|
||||||
bulk_list = []
|
|
||||||
|
|
||||||
total = len(self.missing_videos)
|
total = len(self.missing_videos)
|
||||||
|
videos_added = []
|
||||||
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
|
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
|
||||||
if self.task and self.task.is_stopped():
|
if self.task and self.task.is_stopped():
|
||||||
break
|
break
|
||||||
@@ -253,6 +257,7 @@ class PendingList(PendingIndex):
|
|||||||
self._notify_add(idx, total)
|
self._notify_add(idx, total)
|
||||||
video_details = self.get_youtube_details(youtube_id, vid_type)
|
video_details = self.get_youtube_details(youtube_id, vid_type)
|
||||||
if not video_details:
|
if not video_details:
|
||||||
|
rand_sleep(self.config)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
video_details.update(
|
video_details.update(
|
||||||
@@ -262,28 +267,16 @@ class PendingList(PendingIndex):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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"]
|
url = video_details["vid_thumb_url"]
|
||||||
ThumbManager(youtube_id).download_video_thumb(url)
|
ThumbManager(youtube_id).download_video_thumb(url)
|
||||||
|
es_url = f"ta_download/_doc/{youtube_id}"
|
||||||
|
_, _ = ElasticWrap(es_url).put(video_details)
|
||||||
|
videos_added.append(youtube_id)
|
||||||
|
|
||||||
if len(bulk_list) >= 20:
|
if idx != total:
|
||||||
self._ingest_bulk(bulk_list)
|
rand_sleep(self.config)
|
||||||
bulk_list = []
|
|
||||||
|
|
||||||
self._ingest_bulk(bulk_list)
|
return videos_added
|
||||||
|
|
||||||
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):
|
def _notify_add(self, idx, total):
|
||||||
"""send notification for adding videos to download queue"""
|
"""send notification for adding videos to download queue"""
|
||||||
@@ -321,6 +314,10 @@ class PendingList(PendingIndex):
|
|||||||
else:
|
else:
|
||||||
vid_type = VideoTypeEnum.VIDEOS
|
vid_type = VideoTypeEnum.VIDEOS
|
||||||
|
|
||||||
|
if not vid.get("channel"):
|
||||||
|
print(f"{youtube_id}: skip video not part of channel")
|
||||||
|
return False
|
||||||
|
|
||||||
return self._parse_youtube_details(vid, vid_type)
|
return self._parse_youtube_details(vid, vid_type)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -331,7 +328,7 @@ class PendingList(PendingIndex):
|
|||||||
|
|
||||||
duration = vid.get("duration")
|
duration = vid.get("duration")
|
||||||
if duration and isinstance(duration, int):
|
if duration and isinstance(duration, int):
|
||||||
if duration > 60:
|
if duration > 3 * 60:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return is_shorts(vid["id"])
|
return is_shorts(vid["id"])
|
||||||
@@ -339,9 +336,6 @@ class PendingList(PendingIndex):
|
|||||||
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
|
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
|
||||||
"""parse response"""
|
"""parse response"""
|
||||||
vid_id = vid.get("id")
|
vid_id = vid.get("id")
|
||||||
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
|
|
||||||
"%Y-%m-%d"
|
|
||||||
)
|
|
||||||
|
|
||||||
# build dict
|
# build dict
|
||||||
youtube_details = {
|
youtube_details = {
|
||||||
@@ -351,13 +345,23 @@ class PendingList(PendingIndex):
|
|||||||
"title": vid["title"],
|
"title": vid["title"],
|
||||||
"channel_id": vid["channel_id"],
|
"channel_id": vid["channel_id"],
|
||||||
"duration": get_duration_str(vid["duration"]),
|
"duration": get_duration_str(vid["duration"]),
|
||||||
"published": published,
|
"published": self._build_published(vid),
|
||||||
"timestamp": int(datetime.now().timestamp()),
|
"timestamp": int(datetime.now().timestamp()),
|
||||||
# Pulling enum value out so it is serializable
|
|
||||||
"vid_type": vid_type.value,
|
"vid_type": vid_type.value,
|
||||||
|
"channel_indexed": vid["channel_id"] in self.all_channels,
|
||||||
}
|
}
|
||||||
if self.all_channels:
|
|
||||||
youtube_details.update(
|
|
||||||
{"channel_indexed": vid["channel_id"] in self.all_channels}
|
|
||||||
)
|
|
||||||
return youtube_details
|
return youtube_details
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_published(vid):
|
||||||
|
"""build published date or timestamp"""
|
||||||
|
timestamp = vid["timestamp"]
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
upload_date = vid["upload_date"]
|
||||||
|
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
||||||
|
published = upload_date_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
return published
|
||||||
@@ -4,15 +4,16 @@ Functionality:
|
|||||||
- handle playlist subscriptions
|
- handle playlist subscriptions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from home.src.download import queue # partial import
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from channel.src.index import YoutubeChannel
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from common.src.es_connect import IndexPaginate
|
||||||
from home.src.es.connect import IndexPaginate
|
from common.src.helper import is_missing, rand_sleep
|
||||||
from home.src.index.channel import YoutubeChannel
|
from common.src.urlparser import Parser
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from download.src.thumbnails import ThumbManager
|
||||||
from home.src.index.video_constants import VideoTypeEnum
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from home.src.ta.config import AppConfig
|
from playlist.src.index import YoutubePlaylist
|
||||||
from home.src.ta.urlparser import Parser
|
from video.src.constants import VideoTypeEnum
|
||||||
|
from video.src.index import YoutubeVideo
|
||||||
|
|
||||||
|
|
||||||
class ChannelSubscription:
|
class ChannelSubscription:
|
||||||
@@ -38,11 +39,15 @@ class ChannelSubscription:
|
|||||||
return all_channels
|
return all_channels
|
||||||
|
|
||||||
def get_last_youtube_videos(
|
def get_last_youtube_videos(
|
||||||
self, channel_id, limit=True, query_filter=VideoTypeEnum.UNKNOWN
|
self,
|
||||||
|
channel_id,
|
||||||
|
limit=True,
|
||||||
|
query_filter=None,
|
||||||
|
channel_overwrites=None,
|
||||||
):
|
):
|
||||||
"""get a list of last videos from channel"""
|
"""get a list of last videos from channel"""
|
||||||
queries = self._build_queries(query_filter, limit)
|
query_handler = VideoQueryBuilder(self.config, channel_overwrites)
|
||||||
|
queries = query_handler.build_queries(query_filter)
|
||||||
last_videos = []
|
last_videos = []
|
||||||
|
|
||||||
for vid_type_enum, limit_amount in queries:
|
for vid_type_enum, limit_amount in queries:
|
||||||
@@ -50,90 +55,60 @@ class ChannelSubscription:
|
|||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
"extract_flat": True,
|
"extract_flat": True,
|
||||||
}
|
}
|
||||||
|
vid_type = vid_type_enum.value
|
||||||
|
|
||||||
if limit:
|
if limit:
|
||||||
obs["playlistend"] = limit_amount
|
obs["playlistend"] = limit_amount
|
||||||
|
|
||||||
vid_type = vid_type_enum.value
|
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
||||||
channel = YtWrap(obs, self.config).extract(
|
channel_query = YtWrap(obs, self.config).extract(url)
|
||||||
f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
if not channel_query:
|
||||||
)
|
|
||||||
if not channel:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
last_videos.extend(
|
last_videos.extend(
|
||||||
[(i["id"], i["title"], vid_type) for i in channel["entries"]]
|
[
|
||||||
|
(i["id"], i["title"], vid_type)
|
||||||
|
for i in channel_query["entries"]
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return last_videos
|
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):
|
def find_missing(self):
|
||||||
"""add missing videos from subscribed channels to pending"""
|
"""add missing videos from subscribed channels to pending"""
|
||||||
all_channels = self.get_channels()
|
all_channels = self.get_channels()
|
||||||
if not all_channels:
|
if not all_channels:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = queue.PendingList()
|
|
||||||
pending.get_download()
|
|
||||||
pending.get_indexed()
|
|
||||||
|
|
||||||
missing_videos = []
|
missing_videos = []
|
||||||
|
|
||||||
total = len(all_channels)
|
total = len(all_channels)
|
||||||
for idx, channel in enumerate(all_channels):
|
for idx, channel in enumerate(all_channels):
|
||||||
channel_id = channel["channel_id"]
|
channel_id = channel["channel_id"]
|
||||||
print(f"{channel_id}: find missing videos.")
|
print(f"{channel_id}: find missing videos.")
|
||||||
last_videos = self.get_last_youtube_videos(channel_id)
|
last_videos = self.get_last_youtube_videos(
|
||||||
|
channel_id,
|
||||||
|
channel_overwrites=channel.get("channel_overwrites"),
|
||||||
|
)
|
||||||
|
|
||||||
if last_videos:
|
if last_videos:
|
||||||
|
ids_to_add = is_missing([i[0] for i in last_videos])
|
||||||
for video_id, _, vid_type in last_videos:
|
for video_id, _, vid_type in last_videos:
|
||||||
if video_id not in pending.to_skip:
|
if video_id in ids_to_add:
|
||||||
missing_videos.append((video_id, vid_type))
|
missing_videos.append((video_id, vid_type))
|
||||||
|
|
||||||
if not self.task:
|
if not self.task:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.task:
|
if self.task.is_stopped():
|
||||||
if self.task.is_stopped():
|
self.task.send_progress(["Received Stop signal."])
|
||||||
self.task.send_progress(["Received Stop signal."])
|
break
|
||||||
break
|
|
||||||
|
|
||||||
self.task.send_progress(
|
self.task.send_progress(
|
||||||
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
|
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
|
||||||
progress=(idx + 1) / total,
|
progress=(idx + 1) / total,
|
||||||
)
|
)
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
return missing_videos
|
return missing_videos
|
||||||
|
|
||||||
@@ -146,6 +121,94 @@ class ChannelSubscription:
|
|||||||
channel.upload_to_es()
|
channel.upload_to_es()
|
||||||
channel.sync_to_videos()
|
channel.sync_to_videos()
|
||||||
|
|
||||||
|
return channel.json_data
|
||||||
|
|
||||||
|
|
||||||
|
class VideoQueryBuilder:
|
||||||
|
"""Build queries for yt-dlp."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict, channel_overwrites: dict | None = None):
|
||||||
|
self.config = config
|
||||||
|
self.channel_overwrites = channel_overwrites or {}
|
||||||
|
|
||||||
|
def build_queries(
|
||||||
|
self, video_type: VideoTypeEnum | None, limit: bool = True
|
||||||
|
) -> list[tuple[VideoTypeEnum, int | None]]:
|
||||||
|
"""Build queries for all or specific video type."""
|
||||||
|
query_methods = {
|
||||||
|
VideoTypeEnum.VIDEOS: self.videos_query,
|
||||||
|
VideoTypeEnum.STREAMS: self.streams_query,
|
||||||
|
VideoTypeEnum.SHORTS: self.shorts_query,
|
||||||
|
}
|
||||||
|
|
||||||
|
if video_type:
|
||||||
|
# build query for specific type
|
||||||
|
query_method = query_methods.get(video_type)
|
||||||
|
if query_method:
|
||||||
|
query = query_method(limit)
|
||||||
|
if query[1] != 0:
|
||||||
|
return [query]
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build and return queries for all video types
|
||||||
|
queries = []
|
||||||
|
for build_query in query_methods.values():
|
||||||
|
query = build_query(limit)
|
||||||
|
if query[1] != 0:
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
return queries
|
||||||
|
|
||||||
|
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for videos."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.VIDEOS,
|
||||||
|
overwrite_key="subscriptions_channel_size",
|
||||||
|
config_key="channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for streams."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.STREAMS,
|
||||||
|
overwrite_key="subscriptions_live_channel_size",
|
||||||
|
config_key="live_channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for shorts."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.SHORTS,
|
||||||
|
overwrite_key="subscriptions_shorts_channel_size",
|
||||||
|
config_key="shorts_channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_generic_query(
|
||||||
|
self,
|
||||||
|
video_type: VideoTypeEnum,
|
||||||
|
overwrite_key: str,
|
||||||
|
config_key: str,
|
||||||
|
limit: bool,
|
||||||
|
) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Generic query for video page scraping."""
|
||||||
|
if not limit:
|
||||||
|
return (video_type, None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
overwrite_key in self.channel_overwrites
|
||||||
|
and self.channel_overwrites[overwrite_key] is not None
|
||||||
|
):
|
||||||
|
overwrite = self.channel_overwrites[overwrite_key]
|
||||||
|
return (video_type, overwrite)
|
||||||
|
|
||||||
|
if overwrite := self.config["subscriptions"].get(config_key):
|
||||||
|
return (video_type, overwrite)
|
||||||
|
|
||||||
|
return (video_type, 0)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistSubscription:
|
class PlaylistSubscription:
|
||||||
"""manage the playlist download functionality"""
|
"""manage the playlist download functionality"""
|
||||||
@@ -174,10 +237,6 @@ class PlaylistSubscription:
|
|||||||
|
|
||||||
def process_url_str(self, new_playlists, subscribed=True):
|
def process_url_str(self, new_playlists, subscribed=True):
|
||||||
"""process playlist subscribe form url_str"""
|
"""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):
|
for idx, playlist in enumerate(new_playlists):
|
||||||
playlist_id = playlist["url"]
|
playlist_id = playlist["url"]
|
||||||
if not playlist["type"] == "playlist":
|
if not playlist["type"] == "playlist":
|
||||||
@@ -185,7 +244,6 @@ class PlaylistSubscription:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
playlist_h = YoutubePlaylist(playlist_id)
|
playlist_h = YoutubePlaylist(playlist_id)
|
||||||
playlist_h.all_youtube_ids = all_youtube_ids
|
|
||||||
playlist_h.build_json()
|
playlist_h.build_json()
|
||||||
if not playlist_h.json_data:
|
if not playlist_h.json_data:
|
||||||
message = f"{playlist_h.youtube_id}: failed to extract data"
|
message = f"{playlist_h.youtube_id}: failed to extract data"
|
||||||
@@ -222,15 +280,7 @@ class PlaylistSubscription:
|
|||||||
playlist.build_json()
|
playlist.build_json()
|
||||||
playlist.json_data["playlist_subscribed"] = subscribe_status
|
playlist.json_data["playlist_subscribed"] = subscribe_status
|
||||||
playlist.upload_to_es()
|
playlist.upload_to_es()
|
||||||
|
return playlist.json_data
|
||||||
@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):
|
def find_missing(self):
|
||||||
"""find videos in subscribed playlists not downloaded yet"""
|
"""find videos in subscribed playlists not downloaded yet"""
|
||||||
@@ -238,12 +288,9 @@ class PlaylistSubscription:
|
|||||||
if not all_playlists:
|
if not all_playlists:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
to_ignore = self.get_to_ignore()
|
|
||||||
|
|
||||||
missing_videos = []
|
missing_videos = []
|
||||||
total = len(all_playlists)
|
total = len(all_playlists)
|
||||||
for idx, playlist_id in enumerate(all_playlists):
|
for idx, playlist_id in enumerate(all_playlists):
|
||||||
size_limit = self.config["subscriptions"]["channel_size"]
|
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
is_active = playlist.update_playlist()
|
is_active = playlist.update_playlist()
|
||||||
if not is_active:
|
if not is_active:
|
||||||
@@ -251,27 +298,30 @@ class PlaylistSubscription:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
playlist_entries = playlist.json_data["playlist_entries"]
|
playlist_entries = playlist.json_data["playlist_entries"]
|
||||||
|
size_limit = self.config["subscriptions"]["channel_size"]
|
||||||
if size_limit:
|
if size_limit:
|
||||||
del playlist_entries[size_limit:]
|
del playlist_entries[size_limit:]
|
||||||
|
|
||||||
all_missing = [i for i in playlist_entries if not i["downloaded"]]
|
to_check = [
|
||||||
|
i["youtube_id"]
|
||||||
for video in all_missing:
|
for i in playlist_entries
|
||||||
youtube_id = video["youtube_id"]
|
if i["downloaded"] is False
|
||||||
if youtube_id not in to_ignore:
|
]
|
||||||
missing_videos.append(youtube_id)
|
needs_downloading = is_missing(to_check)
|
||||||
|
missing_videos.extend(needs_downloading)
|
||||||
|
|
||||||
if not self.task:
|
if not self.task:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.task:
|
if self.task.is_stopped():
|
||||||
self.task.send_progress(
|
self.task.send_progress(["Received Stop signal."])
|
||||||
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
|
break
|
||||||
progress=(idx + 1) / total,
|
|
||||||
)
|
self.task.send_progress(
|
||||||
if self.task.is_stopped():
|
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
|
||||||
self.task.send_progress(["Received Stop signal."])
|
progress=(idx + 1) / total,
|
||||||
break
|
)
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
return missing_videos
|
return missing_videos
|
||||||
|
|
||||||
@@ -359,8 +409,10 @@ class SubscriptionHandler:
|
|||||||
|
|
||||||
if item["type"] == "video":
|
if item["type"] == "video":
|
||||||
# extract channel id from video
|
# extract channel id from video
|
||||||
vid = queue.PendingList().get_youtube_details(item["url"])
|
video = YoutubeVideo(item["url"])
|
||||||
channel_id = vid["channel_id"]
|
video.get_from_youtube()
|
||||||
|
video.process_youtube_meta()
|
||||||
|
channel_id = video.channel_id
|
||||||
elif item["type"] == "channel":
|
elif item["type"] == "channel":
|
||||||
channel_id = item["url"]
|
channel_id = item["url"]
|
||||||
else:
|
else:
|
||||||
@@ -375,7 +427,7 @@ class SubscriptionHandler:
|
|||||||
|
|
||||||
def _subscribe(self, channel_id):
|
def _subscribe(self, channel_id):
|
||||||
"""subscribe to channel"""
|
"""subscribe to channel"""
|
||||||
ChannelSubscription().change_subscribe(
|
_ = ChannelSubscription().change_subscribe(
|
||||||
channel_id, channel_subscribed=True
|
channel_id, channel_subscribed=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ from io import BytesIO
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
|
from common.src.helper import is_missing
|
||||||
from mutagen.mp4 import MP4, MP4Cover
|
from mutagen.mp4 import MP4, MP4Cover
|
||||||
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
|
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
|
||||||
|
|
||||||
@@ -326,7 +327,7 @@ class ThumbValidator:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, task):
|
def __init__(self, task=False):
|
||||||
self.task = task
|
self.task = task
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
@@ -346,6 +347,89 @@ class ThumbValidator:
|
|||||||
)
|
)
|
||||||
_ = paginate.get_results()
|
_ = paginate.get_results()
|
||||||
|
|
||||||
|
def clean_up(self):
|
||||||
|
"""clean up all thumbs"""
|
||||||
|
self._clean_up_vids()
|
||||||
|
self._clean_up_channels()
|
||||||
|
self._clean_up_playlists()
|
||||||
|
|
||||||
|
def _clean_up_vids(self):
|
||||||
|
"""clean unneeded vid thumbs"""
|
||||||
|
video_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "videos")
|
||||||
|
video_folders = os.listdir(video_dir)
|
||||||
|
for video_folder in video_folders:
|
||||||
|
folder_path = os.path.join(video_dir, video_folder)
|
||||||
|
thumbs_is = {i.split(".")[0] for i in os.listdir(folder_path)}
|
||||||
|
thumbs_should = self._get_vid_thumbs_should(video_folder)
|
||||||
|
to_delete = thumbs_is - thumbs_should
|
||||||
|
for thumb in to_delete:
|
||||||
|
delete_path = os.path.join(folder_path, f"{thumb}.jpg")
|
||||||
|
os.remove(delete_path)
|
||||||
|
|
||||||
|
if to_delete:
|
||||||
|
message = (
|
||||||
|
f"[thumbs][video][{video_folder}] "
|
||||||
|
+ f"delete {len(to_delete)} unused thumbnails"
|
||||||
|
)
|
||||||
|
print(message)
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress([message])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_vid_thumbs_should(video_folder: str) -> set[str]:
|
||||||
|
"""get indexed"""
|
||||||
|
should_list = [
|
||||||
|
{"prefix": {"youtube_id": {"value": video_folder.lower()}}},
|
||||||
|
{"prefix": {"youtube_id": {"value": video_folder.upper()}}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"query": {"bool": {"should": should_list}},
|
||||||
|
"_source": ["youtube_id"],
|
||||||
|
}
|
||||||
|
result = IndexPaginate("ta_video,ta_download", data).get_results()
|
||||||
|
thumbs_should = {i["youtube_id"] for i in result}
|
||||||
|
|
||||||
|
return thumbs_should
|
||||||
|
|
||||||
|
def _clean_up_channels(self):
|
||||||
|
"""clean unneeded channel thumbs"""
|
||||||
|
channel_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "channels")
|
||||||
|
channel_art = os.listdir(channel_dir)
|
||||||
|
thumbs_is = {"_".join(i.split("_")[:-1]) for i in channel_art}
|
||||||
|
to_delete = is_missing(list(thumbs_is), "ta_channel", "channel_id")
|
||||||
|
for channel_thumb in channel_art:
|
||||||
|
if channel_thumb[:24] in to_delete:
|
||||||
|
delete_path = os.path.join(channel_dir, channel_thumb)
|
||||||
|
os.remove(delete_path)
|
||||||
|
|
||||||
|
if to_delete:
|
||||||
|
message = (
|
||||||
|
"[thumbs][channel] "
|
||||||
|
+ f"delete {len(to_delete)} unused channel art"
|
||||||
|
)
|
||||||
|
print(message)
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress([message])
|
||||||
|
|
||||||
|
def _clean_up_playlists(self):
|
||||||
|
"""clean up unneeded playlist thumbs"""
|
||||||
|
playlist_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "playlists")
|
||||||
|
playlist_art = os.listdir(playlist_dir)
|
||||||
|
thumbs_is = {i.split(".")[0] for i in playlist_art}
|
||||||
|
to_delete = is_missing(list(thumbs_is), "ta_playlist", "playlist_id")
|
||||||
|
for playlist_id in to_delete:
|
||||||
|
delete_path = os.path.join(playlist_dir, f"{playlist_id}.jpg")
|
||||||
|
os.remove(delete_path)
|
||||||
|
|
||||||
|
if to_delete:
|
||||||
|
message = (
|
||||||
|
"[thumbs][playlist] "
|
||||||
|
+ f"delete {len(to_delete)} unused playlist art"
|
||||||
|
)
|
||||||
|
print(message)
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress([message])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_total(index_name):
|
def _get_total(index_name):
|
||||||
"""get total documents in index"""
|
"""get total documents in index"""
|
||||||
219
backend/download/src/yt_dlp_base.py
Normal file
219
backend/download/src/yt_dlp_base.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""
|
||||||
|
functionality:
|
||||||
|
- base class to make all calls to yt-dlp
|
||||||
|
- handle yt-dlp errors
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from http import cookiejar
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
from appsettings.src.config import AppConfig
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class YtWrap:
|
||||||
|
"""wrap calls to yt"""
|
||||||
|
|
||||||
|
OBS_BASE = {
|
||||||
|
"default_search": "ytsearch",
|
||||||
|
"quiet": True,
|
||||||
|
"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()
|
||||||
|
self._add_potoken()
|
||||||
|
|
||||||
|
if getattr(settings, "DEBUG", False):
|
||||||
|
del self.obs["quiet"]
|
||||||
|
print(self.obs)
|
||||||
|
|
||||||
|
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 _add_potoken(self):
|
||||||
|
"""add potoken if enabled"""
|
||||||
|
if self.config["downloads"].get("potoken"):
|
||||||
|
potoken = POTokenHandler(self.config).get()
|
||||||
|
self.obs.update(
|
||||||
|
{
|
||||||
|
"extractor_args": {
|
||||||
|
"youtube": {
|
||||||
|
"po_token": [potoken],
|
||||||
|
"player-client": ["web", "default"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def download(self, url):
|
||||||
|
"""make download request"""
|
||||||
|
self.obs.update({"check_formats": "selected"})
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._validate_cookie()
|
||||||
|
|
||||||
|
return True, True
|
||||||
|
|
||||||
|
def extract(self, url):
|
||||||
|
"""make extract request"""
|
||||||
|
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
||||||
|
try:
|
||||||
|
response = ydl.extract_info(url)
|
||||||
|
except cookiejar.LoadError as err:
|
||||||
|
print(f"cookie file is invalid: {err}")
|
||||||
|
return False
|
||||||
|
except yt_dlp.utils.ExtractorError as err:
|
||||||
|
print(f"{url}: failed to extract: {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: {err}")
|
||||||
|
if "Temporary failure in name resolution" in str(err):
|
||||||
|
raise ConnectionError("lost the internet, abort!") from err
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._validate_cookie()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _validate_cookie(self):
|
||||||
|
"""check cookie and write it back for next use"""
|
||||||
|
if not self.obs.get("cookiefile"):
|
||||||
|
return
|
||||||
|
|
||||||
|
new_cookie = self.obs["cookiefile"].read()
|
||||||
|
old_cookie = RedisArchivist().get_message_str("cookie")
|
||||||
|
if new_cookie and old_cookie != new_cookie:
|
||||||
|
print("refreshed stored cookie")
|
||||||
|
RedisArchivist().set_message("cookie", new_cookie, save=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieHandler:
|
||||||
|
"""handle youtube cookie for yt-dlp"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.cookie_io = False
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""get cookie io stream"""
|
||||||
|
cookie = RedisArchivist().get_message_str("cookie")
|
||||||
|
self.cookie_io = StringIO(cookie)
|
||||||
|
return self.cookie_io
|
||||||
|
|
||||||
|
def set_cookie(self, cookie):
|
||||||
|
"""set cookie str and activate in config"""
|
||||||
|
cookie_clean = cookie.strip("\x00")
|
||||||
|
RedisArchivist().set_message("cookie", cookie_clean, save=True)
|
||||||
|
AppConfig().update_config({"downloads": {"cookie_import": 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")
|
||||||
|
AppConfig().update_config({"downloads": {"cookie_import": False}})
|
||||||
|
print("[cookie]: revoked")
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""validate cookie using the liked videos playlist"""
|
||||||
|
validation = RedisArchivist().get_message_dict("cookie:valid")
|
||||||
|
if validation:
|
||||||
|
print("[cookie]: used cached cookie validation")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print("[cookie] 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().strip("\x00")
|
||||||
|
if modified:
|
||||||
|
cookie_clean = modified.strip("\x00")
|
||||||
|
RedisArchivist().set_message("cookie", cookie_clean)
|
||||||
|
|
||||||
|
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...")
|
||||||
|
|
||||||
|
print(f"[cookie]: validation success: {response}")
|
||||||
|
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, expire=3600)
|
||||||
|
|
||||||
|
|
||||||
|
class POTokenHandler:
|
||||||
|
"""handle po token"""
|
||||||
|
|
||||||
|
REDIS_KEY = "potoken"
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def get(self) -> str | None:
|
||||||
|
"""get PO token"""
|
||||||
|
potoken = RedisArchivist().get_message_str(self.REDIS_KEY)
|
||||||
|
return potoken
|
||||||
|
|
||||||
|
def set_token(self, new_token: str) -> None:
|
||||||
|
"""set new PO token"""
|
||||||
|
RedisArchivist().set_message(self.REDIS_KEY, new_token)
|
||||||
|
AppConfig().update_config({"downloads": {"potoken": True}})
|
||||||
|
|
||||||
|
def revoke_token(self) -> None:
|
||||||
|
"""revoke token"""
|
||||||
|
RedisArchivist().del_message(self.REDIS_KEY)
|
||||||
|
AppConfig().update_config({"downloads": {"potoken": False}})
|
||||||
@@ -10,192 +10,88 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.download.queue import PendingList
|
from appsettings.src.config import AppConfig
|
||||||
from home.src.download.subscriptions import PlaylistSubscription
|
from channel.src.index import YoutubeChannel
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.index.channel import YoutubeChannel
|
from common.src.helper import (
|
||||||
from home.src.index.comments import CommentList
|
get_channel_overwrites,
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
ignore_filelist,
|
||||||
from home.src.index.video import YoutubeVideo, index_new_video
|
rand_sleep,
|
||||||
from home.src.index.video_constants import VideoTypeEnum
|
)
|
||||||
from home.src.ta.config import AppConfig
|
from common.src.ta_redis import RedisQueue
|
||||||
from home.src.ta.helper import ignore_filelist
|
from download.src.queue import PendingList
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
from download.src.subscriptions import PlaylistSubscription
|
||||||
|
from download.src.yt_dlp_base import YtWrap
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
from video.src.comments import CommentList
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
from video.src.index import YoutubeVideo, index_new_video
|
||||||
|
|
||||||
|
|
||||||
class DownloadPostProcess:
|
class DownloaderBase:
|
||||||
"""handle task to run after download queue finishes"""
|
"""base class for shared config"""
|
||||||
|
|
||||||
def __init__(self, download):
|
CACHE_DIR = EnvironmentSettings.CACHE_DIR
|
||||||
self.download = download
|
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
|
||||||
self.now = int(datetime.now().timestamp())
|
CHANNEL_QUEUE = "download:channel"
|
||||||
self.pending = False
|
PLAYLIST_QUEUE = "download:playlist:full"
|
||||||
|
PLAYLIST_QUICK = "download:playlist:quick"
|
||||||
|
VIDEO_QUEUE = "download:video"
|
||||||
|
|
||||||
def run(self):
|
def __init__(self, task):
|
||||||
"""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.task = task
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.cache_dir = EnvironmentSettings.CACHE_DIR
|
self.channel_overwrites = get_channel_overwrites()
|
||||||
self.media_dir = EnvironmentSettings.MEDIA_DIR
|
self.now = int(datetime.now().timestamp())
|
||||||
self._build_obs()
|
|
||||||
self.channels = set()
|
|
||||||
self.videos = set()
|
|
||||||
|
|
||||||
def run_queue(self, auto_only=False):
|
|
||||||
|
class VideoDownloader(DownloaderBase):
|
||||||
|
"""handle the video download functionality"""
|
||||||
|
|
||||||
|
def __init__(self, task=False):
|
||||||
|
super().__init__(task)
|
||||||
|
self.obs = False
|
||||||
|
self._build_obs()
|
||||||
|
|
||||||
|
def run_queue(self, auto_only=False) -> tuple[int, int]:
|
||||||
"""setup download queue in redis loop until no more items"""
|
"""setup download queue in redis loop until no more items"""
|
||||||
self._get_overwrites()
|
downloaded = 0
|
||||||
|
failed = 0
|
||||||
while True:
|
while True:
|
||||||
video_data = self._get_next(auto_only)
|
video_data = self._get_next(auto_only)
|
||||||
if self.task.is_stopped() or not video_data:
|
if self.task.is_stopped() or not video_data:
|
||||||
self._reset_auto()
|
self._reset_auto()
|
||||||
break
|
break
|
||||||
|
|
||||||
youtube_id = video_data.get("youtube_id")
|
if downloaded > 0:
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
|
youtube_id = video_data["youtube_id"]
|
||||||
|
channel_id = video_data["channel_id"]
|
||||||
print(f"{youtube_id}: Downloading video")
|
print(f"{youtube_id}: Downloading video")
|
||||||
self._notify(video_data, "Validate download format")
|
self._notify(video_data, "Validate download format")
|
||||||
|
|
||||||
success = self._dl_single_vid(youtube_id)
|
success = self._dl_single_vid(youtube_id, channel_id)
|
||||||
if not success:
|
if not success:
|
||||||
|
failed += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._notify(video_data, "Add video metadata to index", progress=1)
|
self._notify(video_data, "Add video metadata to index", progress=1)
|
||||||
|
video_type = VideoTypeEnum(video_data["vid_type"])
|
||||||
vid_dict = index_new_video(
|
vid_dict = index_new_video(youtube_id, video_type=video_type)
|
||||||
youtube_id,
|
RedisQueue(self.CHANNEL_QUEUE).add(channel_id)
|
||||||
video_overwrites=self.video_overwrites,
|
RedisQueue(self.VIDEO_QUEUE).add(youtube_id)
|
||||||
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._notify(video_data, "Move downloaded file to archive")
|
||||||
self.move_to_archive(vid_dict)
|
self.move_to_archive(vid_dict)
|
||||||
self._delete_from_pending(youtube_id)
|
self._delete_from_pending(youtube_id)
|
||||||
|
downloaded += 1
|
||||||
|
|
||||||
# post processing
|
# post processing
|
||||||
self._add_subscribed_channels()
|
DownloadPostProcess(self.task).run()
|
||||||
DownloadPostProcess(self).run()
|
|
||||||
|
|
||||||
return self.videos
|
return downloaded, failed
|
||||||
|
|
||||||
def _notify(self, video_data, message, progress=False):
|
def _notify(self, video_data, message, progress=False):
|
||||||
"""send progress notification to task"""
|
"""send progress notification to task"""
|
||||||
@@ -230,13 +126,6 @@ class VideoDownloader:
|
|||||||
|
|
||||||
return response["hits"]["hits"][0]["_source"]
|
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):
|
def _progress_hook(self, response):
|
||||||
"""process the progress_hooks from yt_dlp"""
|
"""process the progress_hooks from yt_dlp"""
|
||||||
progress = False
|
progress = False
|
||||||
@@ -267,12 +156,13 @@ class VideoDownloader:
|
|||||||
"""initial obs"""
|
"""initial obs"""
|
||||||
self.obs = {
|
self.obs = {
|
||||||
"merge_output_format": "mp4",
|
"merge_output_format": "mp4",
|
||||||
"outtmpl": (self.cache_dir + "/download/%(id)s.mp4"),
|
"outtmpl": (self.CACHE_DIR + "/download/%(id)s.mp4"),
|
||||||
"progress_hooks": [self._progress_hook],
|
"progress_hooks": [self._progress_hook],
|
||||||
"noprogress": True,
|
"noprogress": True,
|
||||||
"continuedl": True,
|
"continuedl": True,
|
||||||
"writethumbnail": False,
|
"writethumbnail": False,
|
||||||
"noplaylist": True,
|
"noplaylist": True,
|
||||||
|
"color": "no_color",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_obs_user(self):
|
def _build_obs_user(self):
|
||||||
@@ -327,28 +217,17 @@ class VideoDownloader:
|
|||||||
|
|
||||||
self.obs["postprocessors"] = postprocessors
|
self.obs["postprocessors"] = postprocessors
|
||||||
|
|
||||||
def get_format_overwrites(self, youtube_id):
|
def _set_overwrites(self, obs: dict, channel_id: str) -> None:
|
||||||
"""get overwrites from single video"""
|
"""add overwrites to obs"""
|
||||||
overwrites = self.video_overwrites.get(youtube_id, False)
|
overwrites = self.channel_overwrites.get(channel_id)
|
||||||
if overwrites:
|
if overwrites and overwrites.get("download_format"):
|
||||||
return overwrites.get("download_format", False)
|
obs["format"] = overwrites.get("download_format")
|
||||||
|
|
||||||
return False
|
def _dl_single_vid(self, youtube_id: str, channel_id: str) -> bool:
|
||||||
|
|
||||||
def _dl_single_vid(self, youtube_id):
|
|
||||||
"""download single video"""
|
"""download single video"""
|
||||||
obs = self.obs.copy()
|
obs = self.obs.copy()
|
||||||
format_overwrite = self.get_format_overwrites(youtube_id)
|
self._set_overwrites(obs, channel_id)
|
||||||
if format_overwrite:
|
dl_cache = os.path.join(self.CACHE_DIR, "download")
|
||||||
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)
|
success, message = YtWrap(obs, self.config).download(youtube_id)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -376,7 +255,7 @@ class VideoDownloader:
|
|||||||
host_gid = EnvironmentSettings.HOST_GID
|
host_gid = EnvironmentSettings.HOST_GID
|
||||||
# make folder
|
# make folder
|
||||||
folder = os.path.join(
|
folder = os.path.join(
|
||||||
self.media_dir, vid_dict["channel"]["channel_id"]
|
self.MEDIA_DIR, vid_dict["channel"]["channel_id"]
|
||||||
)
|
)
|
||||||
if not os.path.exists(folder):
|
if not os.path.exists(folder):
|
||||||
os.makedirs(folder)
|
os.makedirs(folder)
|
||||||
@@ -384,8 +263,8 @@ class VideoDownloader:
|
|||||||
os.chown(folder, host_uid, host_gid)
|
os.chown(folder, host_uid, host_gid)
|
||||||
# move media file
|
# move media file
|
||||||
media_file = vid_dict["youtube_id"] + ".mp4"
|
media_file = vid_dict["youtube_id"] + ".mp4"
|
||||||
old_path = os.path.join(self.cache_dir, "download", media_file)
|
old_path = os.path.join(self.CACHE_DIR, "download", media_file)
|
||||||
new_path = os.path.join(self.media_dir, vid_dict["media_url"])
|
new_path = os.path.join(self.MEDIA_DIR, vid_dict["media_url"])
|
||||||
# move media file and fix permission
|
# move media file and fix permission
|
||||||
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
||||||
if host_uid and host_gid:
|
if host_uid and host_gid:
|
||||||
@@ -397,18 +276,6 @@ class VideoDownloader:
|
|||||||
path = f"ta_download/_doc/{youtube_id}?refresh=true"
|
path = f"ta_download/_doc/{youtube_id}?refresh=true"
|
||||||
_, _ = ElasticWrap(path).delete()
|
_, _ = 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):
|
def _reset_auto(self):
|
||||||
"""reset autostart to defaults after queue stop"""
|
"""reset autostart to defaults after queue stop"""
|
||||||
path = "ta_download/_update_by_query"
|
path = "ta_download/_update_by_query"
|
||||||
@@ -423,3 +290,182 @@ class VideoDownloader:
|
|||||||
updated = response.get("updated")
|
updated = response.get("updated")
|
||||||
if updated:
|
if updated:
|
||||||
print(f"[download] reset auto start on {updated} videos.")
|
print(f"[download] reset auto start on {updated} videos.")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadPostProcess(DownloaderBase):
|
||||||
|
"""handle task to run after download queue finishes"""
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""run all functions"""
|
||||||
|
self.auto_delete_all()
|
||||||
|
self.auto_delete_overwrites()
|
||||||
|
self.refresh_playlist()
|
||||||
|
self.match_videos()
|
||||||
|
self.get_comments()
|
||||||
|
|
||||||
|
def auto_delete_all(self):
|
||||||
|
"""handle auto delete"""
|
||||||
|
autodelete_days = self.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)
|
||||||
|
channel_overwrite = "channel.channel_overwrites.autodelete_days"
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{"range": {"player.watched_date": {"lte": now_lte}}},
|
||||||
|
{"term": {"player.watched": True}},
|
||||||
|
],
|
||||||
|
"must_not": [
|
||||||
|
{"exists": {"field": channel_overwrite}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.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}}},
|
||||||
|
{"term": {"player.watched": True}},
|
||||||
|
]
|
||||||
|
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 refresh_playlist(self) -> None:
|
||||||
|
"""match videos with playlists"""
|
||||||
|
self.add_playlists_to_refresh()
|
||||||
|
|
||||||
|
queue = RedisQueue(self.PLAYLIST_QUEUE)
|
||||||
|
while True:
|
||||||
|
total = queue.max_score()
|
||||||
|
playlist_id, idx = queue.get_next()
|
||||||
|
if not playlist_id or not idx or not total:
|
||||||
|
break
|
||||||
|
|
||||||
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
|
playlist.update_playlist(skip_on_empty=True)
|
||||||
|
|
||||||
|
if not self.task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
channel_name = playlist.json_data["playlist_channel"]
|
||||||
|
playlist_title = playlist.json_data["playlist_name"]
|
||||||
|
message = [
|
||||||
|
f"Post Processing Playlists for: {channel_name}",
|
||||||
|
f"{playlist_title} [{idx}/{total}]",
|
||||||
|
]
|
||||||
|
progress = idx / total
|
||||||
|
self.task.send_progress(message, progress=progress)
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
|
def add_playlists_to_refresh(self) -> None:
|
||||||
|
"""add playlists to refresh"""
|
||||||
|
if self.task:
|
||||||
|
message = ["Post Processing Playlists", "Scanning for Playlists"]
|
||||||
|
self.task.send_progress(message)
|
||||||
|
|
||||||
|
self._add_playlist_sub()
|
||||||
|
self._add_channel_playlists()
|
||||||
|
self._add_video_playlists()
|
||||||
|
|
||||||
|
def _add_playlist_sub(self):
|
||||||
|
"""add subscribed playlists to refresh"""
|
||||||
|
subs = PlaylistSubscription().get_playlists()
|
||||||
|
to_add = [i["playlist_id"] for i in subs]
|
||||||
|
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
|
||||||
|
|
||||||
|
def _add_channel_playlists(self):
|
||||||
|
"""add playlists from channels to refresh"""
|
||||||
|
queue = RedisQueue(self.CHANNEL_QUEUE)
|
||||||
|
while True:
|
||||||
|
channel_id, _ = queue.get_next()
|
||||||
|
if not channel_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
channel = YoutubeChannel(channel_id)
|
||||||
|
channel.get_from_es()
|
||||||
|
overwrites = channel.get_overwrites()
|
||||||
|
if "index_playlists" in overwrites:
|
||||||
|
channel.get_all_playlists()
|
||||||
|
to_add = [i[0] for i in channel.all_playlists]
|
||||||
|
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
|
||||||
|
|
||||||
|
def _add_video_playlists(self):
|
||||||
|
"""add other playlists for quick sync"""
|
||||||
|
all_playlists = RedisQueue(self.PLAYLIST_QUEUE).get_all()
|
||||||
|
must_not = [{"terms": {"playlist_id": all_playlists}}]
|
||||||
|
video_ids = RedisQueue(self.VIDEO_QUEUE).get_all()
|
||||||
|
must = [{"terms": {"playlist_entries.youtube_id": video_ids}}]
|
||||||
|
data = {
|
||||||
|
"query": {"bool": {"must_not": must_not, "must": must}},
|
||||||
|
"_source": ["playlist_id"],
|
||||||
|
}
|
||||||
|
playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||||
|
to_add = [i["playlist_id"] for i in playlists]
|
||||||
|
RedisQueue(self.PLAYLIST_QUICK).add_list(to_add)
|
||||||
|
|
||||||
|
def match_videos(self) -> None:
|
||||||
|
"""scan rest of indexed playlists to match videos"""
|
||||||
|
queue = RedisQueue(self.PLAYLIST_QUICK)
|
||||||
|
while True:
|
||||||
|
total = queue.max_score()
|
||||||
|
playlist_id, idx = queue.get_next()
|
||||||
|
if not playlist_id or not idx or not total:
|
||||||
|
break
|
||||||
|
|
||||||
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
|
playlist.get_from_es()
|
||||||
|
playlist.add_vids_to_playlist()
|
||||||
|
playlist.remove_vids_from_playlist()
|
||||||
|
|
||||||
|
if not self.task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = [
|
||||||
|
"Post Processing Playlists.",
|
||||||
|
f"Validate Playlists: - {idx}/{total}",
|
||||||
|
]
|
||||||
|
progress = idx / total
|
||||||
|
self.task.send_progress(message, progress=progress)
|
||||||
|
|
||||||
|
def get_comments(self):
|
||||||
|
"""get comments from youtube"""
|
||||||
|
video_queue = RedisQueue(self.VIDEO_QUEUE)
|
||||||
|
comment_list = CommentList(task=self.task)
|
||||||
|
comment_list.add(video_ids=video_queue.get_all())
|
||||||
|
|
||||||
|
video_queue.clear()
|
||||||
|
comment_list.index()
|
||||||
18
backend/download/urls.py
Normal file
18
backend/download/urls.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""all download API urls"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from download import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.DownloadApiListView.as_view(), name="api-download-list"),
|
||||||
|
path(
|
||||||
|
"aggs/",
|
||||||
|
views.DownloadAggsApiView.as_view(),
|
||||||
|
name="api-download-aggs",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:video_id>/",
|
||||||
|
views.DownloadApiView.as_view(),
|
||||||
|
name="api-download",
|
||||||
|
),
|
||||||
|
]
|
||||||
292
backend/download/views.py
Normal file
292
backend/download/views.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""all download API views"""
|
||||||
|
|
||||||
|
from common.serializers import (
|
||||||
|
AsyncTaskResponseSerializer,
|
||||||
|
ErrorResponseSerializer,
|
||||||
|
)
|
||||||
|
from common.views_base import AdminOnly, ApiBaseView
|
||||||
|
from download.serializers import (
|
||||||
|
AddToDownloadListSerializer,
|
||||||
|
AddToDownloadQuerySerializer,
|
||||||
|
DownloadAggsSerializer,
|
||||||
|
DownloadItemSerializer,
|
||||||
|
DownloadListQuerySerializer,
|
||||||
|
DownloadListQueueDeleteQuerySerializer,
|
||||||
|
DownloadListSerializer,
|
||||||
|
DownloadQueueItemUpdateSerializer,
|
||||||
|
)
|
||||||
|
from download.src.queue import PendingInteract
|
||||||
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from task.tasks import download_pending, extrac_dl
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadApiListView(ApiBaseView):
|
||||||
|
"""resolves to /api/download/
|
||||||
|
GET: returns latest videos in the download queue
|
||||||
|
POST: add a list of videos to download queue
|
||||||
|
DELETE: remove items based on query filter
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_download/_search/"
|
||||||
|
valid_filter = ["pending", "ignore"]
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(DownloadListSerializer()),
|
||||||
|
},
|
||||||
|
parameters=[DownloadListQuerySerializer()],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get download queue list"""
|
||||||
|
query_filter = request.GET.get("filter", False)
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"sort": [
|
||||||
|
{"auto_start": {"order": "desc"}},
|
||||||
|
{"timestamp": {"order": "asc"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = DownloadListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
must_list = []
|
||||||
|
query_filter = validated_data.get("filter")
|
||||||
|
if query_filter:
|
||||||
|
must_list.append({"term": {"status": {"value": query_filter}}})
|
||||||
|
|
||||||
|
filter_channel = validated_data.get("channel")
|
||||||
|
if filter_channel:
|
||||||
|
must_list.append(
|
||||||
|
{"term": {"channel_id": {"value": filter_channel}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.data["query"] = {"bool": {"must": must_list}}
|
||||||
|
|
||||||
|
self.get_document_list(request)
|
||||||
|
serializer = DownloadListSerializer(self.response)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
request=AddToDownloadListSerializer(),
|
||||||
|
parameters=[AddToDownloadQuerySerializer()],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(
|
||||||
|
AsyncTaskResponseSerializer(),
|
||||||
|
description="New async task started",
|
||||||
|
),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(request):
|
||||||
|
"""add list of videos to download queue"""
|
||||||
|
data_serializer = AddToDownloadListSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
|
||||||
|
query_serializer = AddToDownloadQuerySerializer(
|
||||||
|
data=request.query_params
|
||||||
|
)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
|
||||||
|
auto_start = validated_query.get("autostart")
|
||||||
|
print(f"auto_start: {auto_start}")
|
||||||
|
to_add = validated_data["data"]
|
||||||
|
|
||||||
|
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
|
||||||
|
url_str = " ".join(pending)
|
||||||
|
task = extrac_dl.delay(url_str, auto_start=auto_start)
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"message": "add to queue task started",
|
||||||
|
"task_id": task.id,
|
||||||
|
}
|
||||||
|
response_serializer = AsyncTaskResponseSerializer(message)
|
||||||
|
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[DownloadListQueueDeleteQuerySerializer()],
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="Download items deleted"),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete(self, request):
|
||||||
|
"""bulk delete download queue items by filter"""
|
||||||
|
serializer = DownloadListQueueDeleteQuerySerializer(
|
||||||
|
data=request.query_params
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = serializer.validated_data
|
||||||
|
|
||||||
|
query_filter = validated_query["filter"]
|
||||||
|
message = f"delete queue by status: {query_filter}"
|
||||||
|
print(message)
|
||||||
|
PendingInteract(status=query_filter).delete_by_status()
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/download/<video_id>/
|
||||||
|
GET: returns metadata dict of an item in the download queue
|
||||||
|
POST: update status of item to pending or ignore
|
||||||
|
DELETE: forget from download queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_download/_doc/"
|
||||||
|
valid_status = ["pending", "ignore", "ignore-force", "priority"]
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(DownloadItemSerializer()),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(),
|
||||||
|
description="Download item not found",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(self, request, video_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""get download queue item"""
|
||||||
|
self.get_document(video_id)
|
||||||
|
if not self.response:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "Download item not found"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
response_serializer = DownloadItemSerializer(self.response)
|
||||||
|
|
||||||
|
return Response(response_serializer.data, status=self.status_code)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=DownloadQueueItemUpdateSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(
|
||||||
|
DownloadQueueItemUpdateSerializer(),
|
||||||
|
description="Download item update",
|
||||||
|
),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="Bad request"
|
||||||
|
),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(),
|
||||||
|
description="Download item not found",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request, video_id):
|
||||||
|
"""post to video to change status"""
|
||||||
|
data_serializer = DownloadQueueItemUpdateSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
item_status = validated_data["status"]
|
||||||
|
|
||||||
|
if item_status == "ignore-force":
|
||||||
|
extrac_dl.delay(video_id, status="ignore")
|
||||||
|
return Response(data_serializer.data)
|
||||||
|
|
||||||
|
_, status_code = PendingInteract(video_id).get_item()
|
||||||
|
if status_code == 404:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "Download item not found"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
|
print(f"{video_id}: change status to {item_status}")
|
||||||
|
PendingInteract(video_id, item_status).update_status()
|
||||||
|
if item_status == "priority":
|
||||||
|
download_pending.delay(auto_only=True)
|
||||||
|
|
||||||
|
return Response(data_serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="delete download item"),
|
||||||
|
404: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(),
|
||||||
|
description="Download item not found",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete(request, video_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""delete single video from queue"""
|
||||||
|
print(f"{video_id}: delete from queue")
|
||||||
|
PendingInteract(video_id).delete_item()
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadAggsApiView(ApiBaseView):
|
||||||
|
"""resolves to /api/download/aggs/
|
||||||
|
GET: get download aggregations
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_base = "ta_download/_search"
|
||||||
|
valid_filter_view = ["ignore", "pending"]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[DownloadListQueueDeleteQuerySerializer()],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(DownloadAggsSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""get aggs"""
|
||||||
|
serializer = DownloadListQueueDeleteQuerySerializer(
|
||||||
|
data=request.query_params
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = serializer.validated_data
|
||||||
|
|
||||||
|
filter_view = validated_query.get("filter")
|
||||||
|
if filter_view:
|
||||||
|
if filter_view not in self.valid_filter_view:
|
||||||
|
message = f"invalid filter: {filter_view}"
|
||||||
|
return Response({"message": message}, status=400)
|
||||||
|
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"query": {"term": {"status": {"value": filter_view}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"aggs": {
|
||||||
|
"channel_downloads": {
|
||||||
|
"multi_terms": {
|
||||||
|
"size": 30,
|
||||||
|
"terms": [
|
||||||
|
{"field": "channel_name.keyword"},
|
||||||
|
{"field": "channel_id"},
|
||||||
|
],
|
||||||
|
"order": {"_count": "desc"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.get_aggs()
|
||||||
|
serializer = DownloadAggsSerializer(self.response["channel_downloads"])
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
0
backend/playlist/__init__.py
Normal file
0
backend/playlist/__init__.py
Normal file
0
backend/playlist/migrations/__init__.py
Normal file
0
backend/playlist/migrations/__init__.py
Normal file
92
backend/playlist/serializers.py
Normal file
92
backend/playlist/serializers.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""playlist serializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from common.serializers import PaginationSerializer
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistEntrySerializer(serializers.Serializer):
|
||||||
|
"""serialize single playlist entry"""
|
||||||
|
|
||||||
|
youtube_id = serializers.CharField()
|
||||||
|
title = serializers.CharField()
|
||||||
|
uploader = serializers.CharField()
|
||||||
|
idx = serializers.IntegerField()
|
||||||
|
downloaded = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSerializer(serializers.Serializer):
|
||||||
|
"""serialize playlist"""
|
||||||
|
|
||||||
|
playlist_active = serializers.BooleanField()
|
||||||
|
playlist_channel = serializers.CharField()
|
||||||
|
playlist_channel_id = serializers.CharField()
|
||||||
|
playlist_description = serializers.CharField()
|
||||||
|
playlist_entries = PlaylistEntrySerializer(many=True)
|
||||||
|
playlist_id = serializers.CharField()
|
||||||
|
playlist_last_refresh = serializers.CharField()
|
||||||
|
playlist_name = serializers.CharField()
|
||||||
|
playlist_subscribed = serializers.BooleanField()
|
||||||
|
playlist_thumbnail = serializers.CharField()
|
||||||
|
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
|
||||||
|
_index = serializers.CharField(required=False)
|
||||||
|
_score = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistListSerializer(serializers.Serializer):
|
||||||
|
"""serialize list of playlists"""
|
||||||
|
|
||||||
|
data = PlaylistSerializer(many=True)
|
||||||
|
paginate = PaginationSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistListQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize playlist list query params"""
|
||||||
|
|
||||||
|
channel = serializers.CharField(required=False)
|
||||||
|
subscribed = serializers.BooleanField(required=False)
|
||||||
|
type = serializers.ChoiceField(
|
||||||
|
choices=["regular", "custom"], required=False
|
||||||
|
)
|
||||||
|
page = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSingleAddSerializer(serializers.Serializer):
|
||||||
|
"""single item to add"""
|
||||||
|
|
||||||
|
playlist_id = serializers.CharField()
|
||||||
|
playlist_subscribed = serializers.ChoiceField(choices=[True])
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistBulkAddSerializer(serializers.Serializer):
|
||||||
|
"""bulk add playlists serializers"""
|
||||||
|
|
||||||
|
data = PlaylistSingleAddSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSingleUpdate(serializers.Serializer):
|
||||||
|
"""update state of single playlist"""
|
||||||
|
|
||||||
|
playlist_subscribed = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistListCustomPostSerializer(serializers.Serializer):
|
||||||
|
"""serialize list post custom playlist"""
|
||||||
|
|
||||||
|
playlist_name = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistCustomPostSerializer(serializers.Serializer):
|
||||||
|
"""serialize playlist custom action"""
|
||||||
|
|
||||||
|
action = serializers.ChoiceField(
|
||||||
|
choices=["create", "remove", "up", "down", "top", "bottom"]
|
||||||
|
)
|
||||||
|
video_id = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistDeleteQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize playlist delete query params"""
|
||||||
|
|
||||||
|
delete_videos = serializers.BooleanField(required=False)
|
||||||
0
backend/playlist/src/__init__.py
Normal file
0
backend/playlist/src/__init__.py
Normal file
10
backend/playlist/src/constants.py
Normal file
10
backend/playlist/src/constants.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""playlist constants"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTypesEnum(enum.Enum):
|
||||||
|
"""all playlist_type options"""
|
||||||
|
|
||||||
|
REGULAR = "regular"
|
||||||
|
CUSTOM = "custom"
|
||||||
@@ -7,10 +7,11 @@ functionality:
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from home.src.es.connect import ElasticWrap
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.index.generic import YouTubeItem
|
from common.src.index_generic import YouTubeItem
|
||||||
from home.src.index.video import YoutubeVideo
|
from download.src.thumbnails import ThumbManager
|
||||||
|
from video.src import index as ta_video
|
||||||
|
|
||||||
|
|
||||||
class YoutubePlaylist(YouTubeItem):
|
class YoutubePlaylist(YouTubeItem):
|
||||||
@@ -28,7 +29,6 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
super().__init__(youtube_id)
|
super().__init__(youtube_id)
|
||||||
self.all_members = False
|
self.all_members = False
|
||||||
self.nav = False
|
self.nav = False
|
||||||
self.all_youtube_ids = []
|
|
||||||
|
|
||||||
def build_json(self, scrape=False):
|
def build_json(self, scrape=False):
|
||||||
"""collection to create json_data"""
|
"""collection to create json_data"""
|
||||||
@@ -45,7 +45,9 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.process_youtube_meta()
|
self.process_youtube_meta()
|
||||||
self.get_entries()
|
self._ensure_channel()
|
||||||
|
ids_found = self.get_local_vids()
|
||||||
|
self.get_entries(ids_found)
|
||||||
self.json_data["playlist_entries"] = self.all_members
|
self.json_data["playlist_entries"] = self.all_members
|
||||||
self.json_data["playlist_subscribed"] = subscribed
|
self.json_data["playlist_subscribed"] = subscribed
|
||||||
|
|
||||||
@@ -69,25 +71,36 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
"playlist_type": "regular",
|
"playlist_type": "regular",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_entries(self, playlistend=False):
|
def _ensure_channel(self):
|
||||||
"""get all videos in playlist"""
|
"""make sure channel is indexed"""
|
||||||
if playlistend:
|
from channel.src.index import YoutubeChannel
|
||||||
# implement playlist end
|
|
||||||
print(playlistend)
|
channel_id = self.json_data["playlist_channel_id"]
|
||||||
|
channel_handler = YoutubeChannel(channel_id)
|
||||||
|
channel_handler.build_json(upload=True)
|
||||||
|
|
||||||
|
def get_local_vids(self) -> list[str]:
|
||||||
|
"""get local video ids from youtube entries"""
|
||||||
|
entries = self.youtube_meta["entries"]
|
||||||
|
data = {
|
||||||
|
"query": {"terms": {"youtube_id": [i["id"] for i in entries]}},
|
||||||
|
"_source": ["youtube_id"],
|
||||||
|
}
|
||||||
|
indexed_vids = IndexPaginate("ta_video", data).get_results()
|
||||||
|
ids_found = [i["youtube_id"] for i in indexed_vids]
|
||||||
|
|
||||||
|
return ids_found
|
||||||
|
|
||||||
|
def get_entries(self, ids_found) -> None:
|
||||||
|
"""get all videos in playlist, match downloaded with ids_found"""
|
||||||
all_members = []
|
all_members = []
|
||||||
for idx, entry in enumerate(self.youtube_meta["entries"]):
|
for idx, entry in enumerate(self.youtube_meta["entries"]):
|
||||||
if self.all_youtube_ids:
|
|
||||||
downloaded = entry["id"] in self.all_youtube_ids
|
|
||||||
else:
|
|
||||||
downloaded = False
|
|
||||||
if not entry["channel"]:
|
|
||||||
continue
|
|
||||||
to_append = {
|
to_append = {
|
||||||
"youtube_id": entry["id"],
|
"youtube_id": entry["id"],
|
||||||
"title": entry["title"],
|
"title": entry["title"],
|
||||||
"uploader": entry["channel"],
|
"uploader": entry.get("channel"),
|
||||||
"idx": idx,
|
"idx": idx,
|
||||||
"downloaded": downloaded,
|
"downloaded": entry["id"] in ids_found,
|
||||||
}
|
}
|
||||||
all_members.append(to_append)
|
all_members.append(to_append)
|
||||||
|
|
||||||
@@ -128,21 +141,58 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
|
|
||||||
ElasticWrap("_bulk").post(query_str, ndjson=True)
|
ElasticWrap("_bulk").post(query_str, ndjson=True)
|
||||||
|
|
||||||
def update_playlist(self):
|
def remove_vids_from_playlist(self):
|
||||||
|
"""remove playlist ids from videos if needed"""
|
||||||
|
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
|
||||||
|
data = {
|
||||||
|
"query": {"match": {"playlist": self.youtube_id}},
|
||||||
|
"_source": ["youtube_id"],
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
|
||||||
|
}
|
||||||
|
result = IndexPaginate("ta_video", data).get_results()
|
||||||
|
to_remove = [
|
||||||
|
i["youtube_id"] for i in result if i["youtube_id"] not in needed
|
||||||
|
]
|
||||||
|
s = "ctx._source.playlist.removeAll(Collections.singleton(params.rm))"
|
||||||
|
for video_id in to_remove:
|
||||||
|
query = {
|
||||||
|
"script": {
|
||||||
|
"source": s,
|
||||||
|
"lang": "painless",
|
||||||
|
"params": {"rm": self.youtube_id},
|
||||||
|
},
|
||||||
|
"query": {"match": {"youtube_id": video_id}},
|
||||||
|
}
|
||||||
|
path = "ta_video/_update_by_query"
|
||||||
|
_, status_code = ElasticWrap(path).post(query)
|
||||||
|
if status_code == 200:
|
||||||
|
print(f"{self.youtube_id}: removed {video_id} from playlist")
|
||||||
|
|
||||||
|
def update_playlist(self, skip_on_empty=False):
|
||||||
"""update metadata for playlist with data from YouTube"""
|
"""update metadata for playlist with data from YouTube"""
|
||||||
self.get_from_es()
|
self.build_json(scrape=True)
|
||||||
subscribed = self.json_data["playlist_subscribed"]
|
|
||||||
self.get_from_youtube()
|
|
||||||
if not self.json_data:
|
if not self.json_data:
|
||||||
# return false to deactivate
|
# return false to deactivate
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.json_data["playlist_subscribed"] = subscribed
|
if skip_on_empty:
|
||||||
|
has_item_downloaded = any(
|
||||||
|
i["downloaded"] for i in self.json_data["playlist_entries"]
|
||||||
|
)
|
||||||
|
if not has_item_downloaded:
|
||||||
|
return True
|
||||||
|
|
||||||
self.upload_to_es()
|
self.upload_to_es()
|
||||||
|
self.add_vids_to_playlist()
|
||||||
|
self.remove_vids_from_playlist()
|
||||||
|
self.get_playlist_art()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def build_nav(self, youtube_id):
|
def build_nav(self, youtube_id):
|
||||||
"""find next and previous in playlist of a given youtube_id"""
|
"""find next and previous in playlist of a given youtube_id"""
|
||||||
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
all_entries_available = self.json_data["playlist_entries"]
|
all_entries_available = self.json_data["playlist_entries"]
|
||||||
all_entries = [i for i in all_entries_available if i["downloaded"]]
|
all_entries = [i for i in all_entries_available if i["downloaded"]]
|
||||||
current = [i for i in all_entries if i["youtube_id"] == youtube_id]
|
current = [i for i in all_entries if i["youtube_id"] == youtube_id]
|
||||||
@@ -152,18 +202,20 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
|
|
||||||
current_idx = all_entries.index(current[0])
|
current_idx = all_entries.index(current[0])
|
||||||
if current_idx == 0:
|
if current_idx == 0:
|
||||||
previous_item = False
|
previous_item = None
|
||||||
else:
|
else:
|
||||||
previous_item = all_entries[current_idx - 1]
|
previous_item = all_entries[current_idx - 1]
|
||||||
prev_id = previous_item["youtube_id"]
|
prev_id = previous_item["youtube_id"]
|
||||||
previous_item["vid_thumb"] = ThumbManager(prev_id).vid_thumb_path()
|
prev_thumb_path = ThumbManager(prev_id).vid_thumb_path()
|
||||||
|
previous_item["vid_thumb"] = f"{cache_root}/{prev_thumb_path}"
|
||||||
|
|
||||||
if current_idx == len(all_entries) - 1:
|
if current_idx == len(all_entries) - 1:
|
||||||
next_item = False
|
next_item = None
|
||||||
else:
|
else:
|
||||||
next_item = all_entries[current_idx + 1]
|
next_item = all_entries[current_idx + 1]
|
||||||
next_id = next_item["youtube_id"]
|
next_id = next_item["youtube_id"]
|
||||||
next_item["vid_thumb"] = ThumbManager(next_id).vid_thumb_path()
|
next_thumb_path = ThumbManager(next_id).vid_thumb_path()
|
||||||
|
next_item["vid_thumb"] = f"{cache_root}/{next_thumb_path}"
|
||||||
|
|
||||||
self.nav = {
|
self.nav = {
|
||||||
"playlist_meta": {
|
"playlist_meta": {
|
||||||
@@ -208,7 +260,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
i = 0
|
i = 0
|
||||||
while i < len(playlist):
|
while i < len(playlist):
|
||||||
video_id = playlist[i]["youtube_id"]
|
video_id = playlist[i]["youtube_id"]
|
||||||
video = YoutubeVideo(video_id)
|
video = ta_video.YoutubeVideo(video_id)
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
if (
|
if (
|
||||||
channel_id is None
|
channel_id is None
|
||||||
@@ -231,7 +283,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
if i["downloaded"]
|
if i["downloaded"]
|
||||||
]
|
]
|
||||||
for youtube_id in all_youtube_id:
|
for youtube_id in all_youtube_id:
|
||||||
YoutubeVideo(youtube_id).delete_media_file()
|
ta_video.YoutubeVideo(youtube_id).delete_media_file()
|
||||||
|
|
||||||
self.delete_metadata()
|
self.delete_metadata()
|
||||||
|
|
||||||
@@ -265,7 +317,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
)
|
)
|
||||||
self.set_playlist_thumbnail()
|
self.set_playlist_thumbnail()
|
||||||
self.upload_to_es()
|
self.upload_to_es()
|
||||||
video = YoutubeVideo(video_id)
|
video = ta_video.YoutubeVideo(video_id)
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
if "playlist" not in video.json_data:
|
if "playlist" not in video.json_data:
|
||||||
video.json_data["playlist"] = []
|
video.json_data["playlist"] = []
|
||||||
@@ -274,7 +326,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def remove_playlist_from_video(self, video_id):
|
def remove_playlist_from_video(self, video_id):
|
||||||
video = YoutubeVideo(video_id)
|
video = ta_video.YoutubeVideo(video_id)
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
if video.json_data is not None and "playlist" in video.json_data:
|
if video.json_data is not None and "playlist" in video.json_data:
|
||||||
video.json_data["playlist"].remove(self.youtube_id)
|
video.json_data["playlist"].remove(self.youtube_id)
|
||||||
@@ -363,7 +415,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_video_is_watched(self, video_id):
|
def get_video_is_watched(self, video_id):
|
||||||
video = YoutubeVideo(video_id)
|
video = ta_video.YoutubeVideo(video_id)
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
return video.json_data["player"]["watched"]
|
return video.json_data["player"]["watched"]
|
||||||
|
|
||||||
@@ -379,7 +431,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
self.get_playlist_art()
|
self.get_playlist_art()
|
||||||
|
|
||||||
def get_video_metadata(self, video_id):
|
def get_video_metadata(self, video_id):
|
||||||
video = YoutubeVideo(video_id)
|
video = ta_video.YoutubeVideo(video_id)
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
video_json_data = {
|
video_json_data = {
|
||||||
"youtube_id": video.json_data["youtube_id"],
|
"youtube_id": video.json_data["youtube_id"],
|
||||||
52
backend/playlist/src/query_building.py
Normal file
52
backend/playlist/src/query_building.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""build query for playlists"""
|
||||||
|
|
||||||
|
from playlist.src.constants import PlaylistTypesEnum
|
||||||
|
|
||||||
|
|
||||||
|
class QueryBuilder:
|
||||||
|
"""contain functionality"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.request_params = kwargs
|
||||||
|
|
||||||
|
def build_data(self) -> dict:
|
||||||
|
"""build data dict"""
|
||||||
|
data = {}
|
||||||
|
data["query"] = self.build_query()
|
||||||
|
if sort := self.parse_sort():
|
||||||
|
data.update(sort)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def build_query(self) -> dict:
|
||||||
|
"""build query key"""
|
||||||
|
must_list = []
|
||||||
|
channel = self.request_params.get("channel")
|
||||||
|
if channel:
|
||||||
|
must_list.append({"match": {"playlist_channel_id": channel}})
|
||||||
|
|
||||||
|
subscribed = self.request_params.get("subscribed")
|
||||||
|
if subscribed:
|
||||||
|
must_list.append({"match": {"playlist_subscribed": subscribed}})
|
||||||
|
|
||||||
|
playlist_type = self.request_params.get("type")
|
||||||
|
if playlist_type:
|
||||||
|
type_list = self.parse_type(playlist_type)
|
||||||
|
must_list.append(type_list)
|
||||||
|
|
||||||
|
query = {"bool": {"must": must_list}}
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
def parse_type(self, playlist_type: str) -> dict:
|
||||||
|
"""parse playlist type"""
|
||||||
|
if not hasattr(PlaylistTypesEnum, playlist_type.upper()):
|
||||||
|
raise ValueError(f"'{playlist_type}' not in PlaylistTypesEnum")
|
||||||
|
|
||||||
|
type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value
|
||||||
|
|
||||||
|
return {"match": {"playlist_type.keyword": type_parsed}}
|
||||||
|
|
||||||
|
def parse_sort(self) -> dict:
|
||||||
|
"""return sort"""
|
||||||
|
return {"sort": [{"playlist_name.keyword": {"order": "asc"}}]}
|
||||||
0
backend/playlist/tests/__init__.py
Normal file
0
backend/playlist/tests/__init__.py
Normal file
0
backend/playlist/tests/test_src/__init__.py
Normal file
0
backend/playlist/tests/test_src/__init__.py
Normal file
30
backend/playlist/tests/test_src/test_query_building.py
Normal file
30
backend/playlist/tests/test_src/test_query_building.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""test playlist query building"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playlist.src.query_building import QueryBuilder
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_data():
|
||||||
|
"""test for correct key building"""
|
||||||
|
qb = QueryBuilder(
|
||||||
|
channel="test_channel",
|
||||||
|
subscribed=True,
|
||||||
|
type="regular",
|
||||||
|
)
|
||||||
|
result = qb.build_data()
|
||||||
|
must_list = result["query"]["bool"]["must"]
|
||||||
|
assert "query" in result
|
||||||
|
assert "sort" in result
|
||||||
|
assert result["sort"] == [{"playlist_name.keyword": {"order": "asc"}}]
|
||||||
|
assert {"match": {"playlist_channel_id": "test_channel"}} in must_list
|
||||||
|
assert {"match": {"playlist_subscribed": True}} in must_list
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_type():
|
||||||
|
"""validate type"""
|
||||||
|
qb = QueryBuilder(type="regular")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
qb.parse_type("invalid")
|
||||||
|
|
||||||
|
result = qb.parse_type("custom")
|
||||||
|
assert result == {"match": {"playlist_type.keyword": "custom"}}
|
||||||
27
backend/playlist/urls.py
Normal file
27
backend/playlist/urls.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""all playlist API urls"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from playlist import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"",
|
||||||
|
views.PlaylistApiListView.as_view(),
|
||||||
|
name="api-playlist-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"custom/",
|
||||||
|
views.PlaylistCustomApiListView.as_view(),
|
||||||
|
name="api-custom-playlist-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"custom/<slug:playlist_id>/",
|
||||||
|
views.PlaylistCustomApiView.as_view(),
|
||||||
|
name="api-custom-playlist",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:playlist_id>/",
|
||||||
|
views.PlaylistApiView.as_view(),
|
||||||
|
name="api-playlist",
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user