mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
Compare commits
585 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a347e50e48 | ||
|
|
22b957aee4 | ||
|
|
68ab03d95f | ||
|
|
97515c4d05 | ||
|
|
ac6d43af8c | ||
|
|
391c3412d2 | ||
|
|
136fb81d41 | ||
|
|
633b12e2d3 | ||
|
|
fd93c0dc3d | ||
|
|
8f3dc83543 | ||
|
|
c44cac2e9a | ||
|
|
78d1f70eda | ||
|
|
b7ee17ffba | ||
|
|
562393b77e | ||
|
|
c5e5016728 | ||
|
|
e2ceef5217 | ||
|
|
6b6eb11cc0 | ||
|
|
e483fc203b | ||
|
|
107246ff0b | ||
|
|
971065b8a8 | ||
|
|
578c50ea96 | ||
|
|
1b7840b715 | ||
|
|
ab8fd29434 | ||
|
|
53fa1e42a0 | ||
|
|
46d36fbbc6 | ||
|
|
7b6adf62d7 | ||
|
|
d2e764316d | ||
|
|
e984b234e3 | ||
|
|
0dc8898e8b | ||
|
|
107a891359 | ||
|
|
bccee9ebc1 | ||
|
|
3868f02c45 | ||
|
|
a29704af55 | ||
|
|
7139c88c35 | ||
|
|
04394b7eac | ||
|
|
c2ac08fdfd | ||
|
|
460677aeea | ||
|
|
46695588f7 | ||
|
|
cfe58db222 | ||
|
|
f991338376 | ||
|
|
704fb1f529 | ||
|
|
1918b7d633 | ||
|
|
a7b4db83fa | ||
|
|
5d424adbe8 | ||
|
|
56199aef81 | ||
|
|
fe8309fd45 | ||
|
|
c1ff5e35aa | ||
|
|
b2496dc4e7 | ||
|
|
c4bf21141c | ||
|
|
84e4623a2c | ||
|
|
e9f38169c2 | ||
|
|
8fec2060ca | ||
|
|
d06455d3c8 | ||
|
|
2b8bed704d | ||
|
|
34c938b1ce | ||
|
|
32442e0b64 | ||
|
|
7989fa3673 | ||
|
|
c389462dde | ||
|
|
db40421faa | ||
|
|
8f40f6065f | ||
|
|
67239d921d | ||
|
|
9393f72cc1 | ||
|
|
99c2b3b4d7 | ||
|
|
092ac2c735 | ||
|
|
38389a81e6 | ||
|
|
a8a52f3251 | ||
|
|
f9336a8115 | ||
|
|
c27f28c0c1 | ||
|
|
546a05ff0b | ||
|
|
5744a55b06 | ||
|
|
703926bca5 | ||
|
|
7ed9b11a89 | ||
|
|
c274bd7fbb | ||
|
|
bbff67899f | ||
|
|
8d59b0b27b | ||
|
|
37cc72a040 | ||
|
|
205deb150b | ||
|
|
843940f552 | ||
|
|
5f9fa4c11f | ||
|
|
ed364a26c9 | ||
|
|
bfa7019324 | ||
|
|
887a177581 | ||
|
|
d3ecefb7d6 | ||
|
|
95975d5756 | ||
|
|
c6b23690f4 | ||
|
|
4fc12bca4a | ||
|
|
647c1316ba | ||
|
|
2dae24a02b | ||
|
|
705041ea81 | ||
|
|
bc046b3c92 | ||
|
|
0f0e855507 | ||
|
|
abf16fd104 | ||
|
|
7086f41d64 | ||
|
|
57cae0e4be | ||
|
|
0c36602133 | ||
|
|
2d4e65176f | ||
|
|
14cf2d515a | ||
|
|
f0c69fd286 | ||
|
|
86f39cb7f1 | ||
|
|
a8a3c70e55 | ||
|
|
b928de7d50 | ||
|
|
c39724ad91 | ||
|
|
270d0479b4 | ||
|
|
3d8fe8f1e9 | ||
|
|
7d6bef5507 | ||
|
|
3d87001958 | ||
|
|
2c6a636c73 | ||
|
|
3caa2ca705 | ||
|
|
70d900f346 | ||
|
|
d4c1622463 | ||
|
|
c98fd6140b | ||
|
|
1de3022ec5 | ||
|
|
e25d470565 | ||
|
|
87b8035e06 | ||
|
|
7e906ad78e | ||
|
|
ff081d7dc8 | ||
|
|
4eab95793e | ||
|
|
b6a5738f7d | ||
|
|
cae040d45d | ||
|
|
8ed0030223 | ||
|
|
c4d73eae39 | ||
|
|
3835954024 | ||
|
|
e731527104 | ||
|
|
c0b7cc5fe8 | ||
|
|
6b5c745962 | ||
|
|
4ae0796b3d | ||
|
|
aa9e78f858 | ||
|
|
abdf2d7864 | ||
|
|
7c91508f3c | ||
|
|
661d1690d2 | ||
|
|
ac3a83a1aa | ||
|
|
30843c0c4c | ||
|
|
8f060e8b3a | ||
|
|
763d1430fd | ||
|
|
b6afcafc98 | ||
|
|
4eec542ef8 | ||
|
|
d204d6ad64 | ||
|
|
37e11c8fc4 | ||
|
|
bdf59b8c19 | ||
|
|
60a9f6843b | ||
|
|
2ad997f6ff | ||
|
|
e261642e6c | ||
|
|
2dc07bbe84 | ||
|
|
20f50f6fd9 | ||
|
|
92df3f7070 | ||
|
|
ff60bbec1a | ||
|
|
2a5889f5b2 | ||
|
|
36896d7a45 | ||
|
|
1fd37b9a51 | ||
|
|
f2a4c1f665 | ||
|
|
8e03427844 | ||
|
|
ec06a52fd4 | ||
|
|
c081354150 | ||
|
|
ac2eb2e3ad | ||
|
|
b26d4815f1 | ||
|
|
aad604c655 | ||
|
|
df3e6a0ebc | ||
|
|
f403004769 | ||
|
|
1d07b26a01 | ||
|
|
ddd1105d5b | ||
|
|
374c05952b | ||
|
|
f8919b0d98 | ||
|
|
712fb08563 | ||
|
|
5d2bc2606c | ||
|
|
ae56407405 | ||
|
|
10d558f136 | ||
|
|
9903f0b75d | ||
|
|
2cfea1df0f | ||
|
|
a7b9523352 | ||
|
|
1e9fd28653 | ||
|
|
690fa10be4 | ||
|
|
7c0c513f8f | ||
|
|
dd6c4df62b | ||
|
|
3c5032ee71 | ||
|
|
8f1dd2ffeb | ||
|
|
4ecfbb237e | ||
|
|
2bf4e8cd0f | ||
|
|
4594cce47a | ||
|
|
ef1518a8dd | ||
|
|
e733b9872f | ||
|
|
9367d76dac | ||
|
|
33c1db4c9f | ||
|
|
51dbab211a | ||
|
|
d000e777f1 | ||
|
|
7d2f0cdab3 | ||
|
|
4e5ecc80aa | ||
|
|
a9b729c3f5 | ||
|
|
450b384419 | ||
|
|
50f0c37fa5 | ||
|
|
ae3c839812 | ||
|
|
ae16e1202c | ||
|
|
20f54b2bf6 | ||
|
|
2196660aec | ||
|
|
d388f45137 | ||
|
|
569dce1ba4 | ||
|
|
2156ea6283 | ||
|
|
df6f98a806 | ||
|
|
16cb9edcbd | ||
|
|
36968631a6 | ||
|
|
72d1520400 | ||
|
|
effa03b2a5 | ||
|
|
97bfc6f802 | ||
|
|
157c87ce4d | ||
|
|
dd06ba57f5 | ||
|
|
efea46b08b | ||
|
|
fc867b7f77 | ||
|
|
dba93b1d20 | ||
|
|
e871010366 | ||
|
|
d29a9ef88c | ||
|
|
1d5a005888 | ||
|
|
06046c8961 | ||
|
|
d8ead97049 | ||
|
|
7cbc71f7fb | ||
|
|
36879c10f2 | ||
|
|
ac84916974 | ||
|
|
b19184d7aa | ||
|
|
e45e4fc86f | ||
|
|
b63650799a | ||
|
|
6e07dce3f1 | ||
|
|
a164194c61 | ||
|
|
87accc2f63 | ||
|
|
c717df50f9 | ||
|
|
98d7acd3c0 | ||
|
|
e04afff04a | ||
|
|
17ce532326 | ||
|
|
14c54b60f1 | ||
|
|
714f1b5cf5 | ||
|
|
5eeca5fc94 | ||
|
|
cc59718997 | ||
|
|
cc450f0a97 | ||
|
|
77a3edf63a | ||
|
|
38c89402f8 | ||
|
|
14dd28b7fb | ||
|
|
4a45f536bb | ||
|
|
f6acbb0c98 | ||
|
|
f5ed272807 | ||
|
|
a2656184e8 | ||
|
|
80b72d8cfa | ||
|
|
d6f837238f | ||
|
|
2f31a17be3 | ||
|
|
6d37b44e75 | ||
|
|
3acb5ff0f5 | ||
|
|
1d2ca3c436 | ||
|
|
7768d137c4 | ||
|
|
3e966cd1dd | ||
|
|
1cfd26227b | ||
|
|
89a689c5a0 | ||
|
|
9c6c2a081b | ||
|
|
3e7bd3d431 | ||
|
|
f08a30e303 | ||
|
|
5eb118478b | ||
|
|
16f4f0c9ed | ||
|
|
5fbe38f8d2 | ||
|
|
284ba87fe7 | ||
|
|
fee8913824 | ||
|
|
e766c4dd54 | ||
|
|
688ca2b405 | ||
|
|
275a93b9b5 | ||
|
|
1dd0446701 | ||
|
|
6021bdf741 | ||
|
|
94c8bd36bf | ||
|
|
2070f9e281 | ||
|
|
ca6ab0cd02 | ||
|
|
0db557384a | ||
|
|
c4b2fae85a | ||
|
|
fb260356aa | ||
|
|
61c187fc72 | ||
|
|
92d3068be0 | ||
|
|
bb9b95b4a4 | ||
|
|
5ea194059b | ||
|
|
5cfea5892a | ||
|
|
812d25b987 | ||
|
|
0d1d1f2722 | ||
|
|
137241f873 | ||
|
|
59587cbe66 | ||
|
|
e3269a21ce | ||
|
|
af8e993814 | ||
|
|
1ab1f638a3 | ||
|
|
9c22a52c14 | ||
|
|
7e0ea5eae0 | ||
|
|
2f66b7c183 | ||
|
|
f416c7c251 | ||
|
|
17e6e8c238 | ||
|
|
a2c69be861 | ||
|
|
6787e4912f | ||
|
|
e2074c1921 | ||
|
|
e0bb2d925e | ||
|
|
821dccc380 | ||
|
|
429a6a6bdc | ||
|
|
fb89a7c977 | ||
|
|
d89f634213 | ||
|
|
f84265f2a5 | ||
|
|
4d5ffaf223 | ||
|
|
d8c0850fd8 | ||
|
|
594596d4f5 | ||
|
|
1d9741a019 | ||
|
|
1f11f80e1e | ||
|
|
322c7c9323 | ||
|
|
6f316977ad | ||
|
|
50e23cbfd7 | ||
|
|
108bd52e7f | ||
|
|
28347a239b | ||
|
|
a71dcb31a0 | ||
|
|
a3511d6363 | ||
|
|
d066c3748a | ||
|
|
ba5623c248 | ||
|
|
8ac0aa4f5f | ||
|
|
d448561767 | ||
|
|
96f501b0fc | ||
|
|
60a61e78e3 | ||
|
|
aa27fcc362 | ||
|
|
b236c965eb | ||
|
|
cc41ab613f | ||
|
|
72aad57a2d | ||
|
|
6b9d621cf7 | ||
|
|
34bfaa0ed7 | ||
|
|
e54b70ba08 | ||
|
|
d09674cd95 | ||
|
|
bdfad0f314 | ||
|
|
c68249c314 | ||
|
|
bd202638ef | ||
|
|
c33d456743 | ||
|
|
1e78590f8c | ||
|
|
98f201e803 | ||
|
|
a26cc758cd | ||
|
|
21913306c4 | ||
|
|
f4dce6db01 | ||
|
|
eba261b6af | ||
|
|
e61539fc80 | ||
|
|
f38d3a4f17 | ||
|
|
705fb6bd66 | ||
|
|
336aa7f3cb | ||
|
|
8936b55d3c | ||
|
|
c4de8ce69e | ||
|
|
0704ec97fd | ||
|
|
29f75494a0 | ||
|
|
8da8c86e1d | ||
|
|
2e78acf8cf | ||
|
|
21b32f302c | ||
|
|
30e39de568 | ||
|
|
861bcee537 | ||
|
|
4dace2d333 | ||
|
|
64f19237f9 | ||
|
|
d824cd1646 | ||
|
|
062359a34d | ||
|
|
e39cf39401 | ||
|
|
1107423893 | ||
|
|
ba76a4d5f2 | ||
|
|
65e7b55200 | ||
|
|
84fbbfa3c4 | ||
|
|
868ab4e3d3 | ||
|
|
5f09aabfe6 | ||
|
|
168aa07cf1 | ||
|
|
dc649ffd88 | ||
|
|
447c0ea25c | ||
|
|
13afd0ed6d | ||
|
|
0d20cb8fa5 | ||
|
|
fe9f83a6af | ||
|
|
8f97688bb1 | ||
|
|
e6c580c9bd | ||
|
|
0822c9aad2 | ||
|
|
5940efbddf | ||
|
|
c1d0189126 | ||
|
|
c044177eb6 | ||
|
|
438c90e96d | ||
|
|
4e95e90974 | ||
|
|
90fadc9bf4 | ||
|
|
983a6675c5 | ||
|
|
4cb89f3012 | ||
|
|
883390308b | ||
|
|
ed91121f2e | ||
|
|
1bddebdae1 | ||
|
|
90fa73982b | ||
|
|
b21d8c45ba | ||
|
|
df7e6422df | ||
|
|
0d3eed81ba | ||
|
|
9630a74f58 | ||
|
|
1ea70ab029 | ||
|
|
b8f0327002 | ||
|
|
ad83d968dc | ||
|
|
7cbbf2a270 | ||
|
|
b470ae68db | ||
|
|
d0ce2fdd6d | ||
|
|
d08298b9c3 | ||
|
|
f84ef95d3c | ||
|
|
133a15496a | ||
|
|
62313c33a6 | ||
|
|
7eeecd8701 | ||
|
|
24a25821fc | ||
|
|
4310e164bf | ||
|
|
e8f13b78c3 | ||
|
|
0ec60b04d2 | ||
|
|
113f4c0bde | ||
|
|
0c0e802cf2 | ||
|
|
013fde4c55 | ||
|
|
9e4bdf0a09 | ||
|
|
bb291dc028 | ||
|
|
4ca8f5bcbb | ||
|
|
b2b108bfe5 | ||
|
|
b9b3ded71c | ||
|
|
dec92ecd31 | ||
|
|
1da3ca26de | ||
|
|
5e50dd8795 | ||
|
|
6fed444e43 | ||
|
|
911644531b | ||
|
|
a01814069a | ||
|
|
37922a44ab | ||
|
|
0eb99381ac | ||
|
|
f9d452a1ba | ||
|
|
01e356c156 | ||
|
|
a80af16e6a | ||
|
|
022270efa0 | ||
|
|
8e858045d6 | ||
|
|
a5ac0e78fc | ||
|
|
f2069caa62 | ||
|
|
2ef9ab0dcf | ||
|
|
44b1b3b572 | ||
|
|
db6755a009 | ||
|
|
3801dd7aab | ||
|
|
dd911cc711 | ||
|
|
ddb03289b4 | ||
|
|
1e5298a8d3 | ||
|
|
8894d51877 | ||
|
|
2650bee7e9 | ||
|
|
c83e2be866 | ||
|
|
1eda68db7a | ||
|
|
a7b8e54747 | ||
|
|
4f0312b44b | ||
|
|
0cca34a2dc | ||
|
|
8a89f43afd | ||
|
|
aa693f7a6a | ||
|
|
914c612db9 | ||
|
|
76f2f2abc4 | ||
|
|
c956672587 | ||
|
|
14a6bf4473 | ||
|
|
72af398a99 | ||
|
|
292a443a09 | ||
|
|
830b61ae43 | ||
|
|
c486f641a8 | ||
|
|
dbbc2f5735 | ||
|
|
3435d77d20 | ||
|
|
f89df3284e | ||
|
|
672f135b51 | ||
|
|
07f6c65ae7 | ||
|
|
a85ea2cf01 | ||
|
|
d77eb9e2ec | ||
|
|
972d214631 | ||
|
|
29739ede36 | ||
|
|
60e82b7b01 | ||
|
|
eb9dd391d5 | ||
|
|
efefd9038f | ||
|
|
09a43e4d81 | ||
|
|
29228612aa | ||
|
|
625dab0357 | ||
|
|
8a15f3477f | ||
|
|
6d3616d524 | ||
|
|
862abb0d57 | ||
|
|
7e21e3bb1a | ||
|
|
63c10da57d | ||
|
|
36c2bd3eee | ||
|
|
9564e86487 | ||
|
|
6685d868c4 | ||
|
|
69e0cba86e | ||
|
|
844ef4667a | ||
|
|
2ee40ce5f8 | ||
|
|
e7cb13e8b0 | ||
|
|
e1e60aa056 | ||
|
|
bf60bbab54 | ||
|
|
452a493df3 | ||
|
|
17fbf1d4f7 | ||
|
|
3bdbf30c21 | ||
|
|
3586dba677 | ||
|
|
dce6dbdd7a | ||
|
|
cf4f85f1bd | ||
|
|
a30539494c | ||
|
|
2e891a457f | ||
|
|
6f99d8ea85 | ||
|
|
f403b5c97a | ||
|
|
4c19eb0dc6 | ||
|
|
7a506e01ac | ||
|
|
f035319e5d | ||
|
|
d676903825 | ||
|
|
110f51f711 | ||
|
|
6c71115514 | ||
|
|
9914e65c4c | ||
|
|
d6c551a381 | ||
|
|
6cd6d3ef9a | ||
|
|
a0f27d8116 | ||
|
|
54ab3e8981 | ||
|
|
16984bc386 | ||
|
|
aab30a3f50 | ||
|
|
ebfd974fd8 | ||
|
|
04ce2dacd1 | ||
|
|
dec4652ae8 | ||
|
|
44d86ebfc0 | ||
|
|
d4262960c2 | ||
|
|
50ec5d9f1a | ||
|
|
e603ff7a49 | ||
|
|
d4a4c99744 | ||
|
|
dbf40fe687 | ||
|
|
1289e95c93 | ||
|
|
d5e6ee0a0d | ||
|
|
1cd557062e | ||
|
|
c4972f7dc9 | ||
|
|
978714a74d | ||
|
|
c819a95cb5 | ||
|
|
a756719cf1 | ||
|
|
c56fa7a6b8 | ||
|
|
6ad1207a0b | ||
|
|
f98bb183da | ||
|
|
a3f7d465d5 | ||
|
|
55048b60e3 | ||
|
|
d73071b20f | ||
|
|
f8407610c8 | ||
|
|
9f3d4ff7ff | ||
|
|
62848c0ea0 | ||
|
|
95fba1cb84 | ||
|
|
123509eb9d | ||
|
|
ba967f47d9 | ||
|
|
a83b9fdf62 | ||
|
|
298ca3260e | ||
|
|
e69a8f3449 | ||
|
|
b629836b3d | ||
|
|
65d14644b4 | ||
|
|
ef030b3da0 | ||
|
|
ad46a10ce6 | ||
|
|
f1a82e8843 | ||
|
|
1adbc70f53 | ||
|
|
bfec229ca9 | ||
|
|
fd7ac0029d | ||
|
|
4a6724c94b | ||
|
|
8afaa01184 | ||
|
|
e861fca092 | ||
|
|
f83523cfa2 | ||
|
|
d8d0695963 | ||
|
|
03b50420f5 | ||
|
|
9734bca239 | ||
|
|
e8e1560106 | ||
|
|
2573928e2a | ||
|
|
1cda7b9439 | ||
|
|
192a134c04 | ||
|
|
9b4d18bc18 | ||
|
|
8eb22ce45a | ||
|
|
ff60005442 | ||
|
|
49db83260d | ||
|
|
06358a0333 | ||
|
|
c3fb9c0073 | ||
|
|
fa9a7868ff | ||
|
|
66ee67da30 | ||
|
|
289a8f48d0 | ||
|
|
04a684b359 | ||
|
|
b535f28ac5 | ||
|
|
47427d4b0f | ||
|
|
7083c512be | ||
|
|
08545b8c92 | ||
|
|
dcefc6541c | ||
|
|
7f64642003 | ||
|
|
df6d821135 | ||
|
|
bf409fb240 | ||
|
|
442e2bdc0e | ||
|
|
10582a3741 | ||
|
|
48734727ad | ||
|
|
067a0ce0a9 | ||
|
|
14d1cc26d7 | ||
|
|
8f8a7e316e | ||
|
|
30b97c4fc5 | ||
|
|
93a4846924 | ||
|
|
d10599429f | ||
|
|
29e22d1c0e | ||
|
|
96c3a509a2 | ||
|
|
b01ec466f1 | ||
|
|
0d77884758 | ||
|
|
6591d1dac7 | ||
|
|
2a107eebd3 | ||
|
|
57e863a4bf | ||
|
|
fcbf794947 | ||
|
|
29cb481f78 | ||
|
|
5ad4824c4b | ||
|
|
14fa187a4e | ||
|
|
5dac7acc93 | ||
|
|
22978aa132 | ||
|
|
549aefe453 | ||
|
|
5a32a5151a | ||
|
|
3c9afeddd7 | ||
|
|
3746b614a2 |
449 changed files with 86983 additions and 17455 deletions
161
.github/workflows/backend-tests.yml
vendored
Normal file
161
.github/workflows/backend-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
name: Backend Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'core/**'
|
||||
- 'tests/**'
|
||||
- 'dispatcharr/**'
|
||||
- 'pyproject.toml'
|
||||
- 'version.py'
|
||||
- 'manage.py'
|
||||
- 'scripts/ci_backend_test_labels.py'
|
||||
- 'scripts/ci_bootstrap_backend.sh'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'core/**'
|
||||
- 'tests/**'
|
||||
- 'dispatcharr/**'
|
||||
- 'pyproject.toml'
|
||||
- 'version.py'
|
||||
- 'manage.py'
|
||||
- 'scripts/ci_backend_test_labels.py'
|
||||
- 'scripts/ci_bootstrap_backend.sh'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full_suite:
|
||||
description: Run the full backend test suite
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: backend-tests-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan test groups
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
labels: ${{ steps.resolve.outputs.labels }}
|
||||
has_tests: ${{ steps.resolve.outputs.has_tests }}
|
||||
base_image: ${{ steps.base_image.outputs.image }}
|
||||
sync_python_deps: ${{ steps.base_image.outputs.sync_python_deps }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Collect changed paths
|
||||
id: changed
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: > /tmp/changed_paths.txt
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
if [ "${{ inputs.full_suite }}" = "true" ]; then
|
||||
: > /tmp/changed_paths.txt
|
||||
echo "mode=full" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git diff --name-only HEAD~1 HEAD > /tmp/changed_paths.txt || true
|
||||
echo "mode=diff" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
git fetch origin "${{ github.base_ref }}"
|
||||
git diff --name-only "origin/${{ github.base_ref }}...HEAD" > /tmp/changed_paths.txt
|
||||
echo "mode=pr" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
|
||||
git ls-files > /tmp/changed_paths.txt
|
||||
echo "mode=initial-push" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" > /tmp/changed_paths.txt
|
||||
echo "mode=push" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Select base image
|
||||
id: base_image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
TARGET_BRANCH="${{ github.base_ref }}"
|
||||
else
|
||||
TARGET_BRANCH="${{ github.ref_name }}"
|
||||
fi
|
||||
if [ "$TARGET_BRANCH" = "main" ]; then
|
||||
TAG="base"
|
||||
else
|
||||
TAG="base-dev"
|
||||
fi
|
||||
REPO_OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||
REPO_NAME="$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "image=ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
if grep -qx 'pyproject.toml' /tmp/changed_paths.txt || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "sync_python_deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "sync_python_deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Using base image: ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}"
|
||||
|
||||
- name: Resolve Django test labels
|
||||
id: resolve
|
||||
env:
|
||||
FULL_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.full_suite == true && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LABELS=$(python scripts/ci_backend_test_labels.py < /tmp/changed_paths.txt)
|
||||
echo "labels=${LABELS}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${LABELS}" = "[]" ]; then
|
||||
echo "has_tests=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_tests=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Selected labels: ${LABELS}"
|
||||
|
||||
test:
|
||||
name: ${{ matrix.label }}
|
||||
needs: plan
|
||||
if: needs.plan.outputs.has_tests == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ needs.plan.outputs.base_image }}
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
options: --entrypoint ""
|
||||
strategy:
|
||||
max-parallel: 6
|
||||
fail-fast: false
|
||||
matrix:
|
||||
label: ${{ fromJSON(needs.plan.outputs.labels) }}
|
||||
env:
|
||||
DISPATCHARR_ENV: aio
|
||||
DJANGO_SECRET_KEY: ci-test-secret-key
|
||||
POSTGRES_DB: dispatcharr
|
||||
POSTGRES_USER: dispatch
|
||||
POSTGRES_PASSWORD: secret
|
||||
DISPATCHARR_LOG_LEVEL: WARNING
|
||||
SYNC_PYTHON_DEPS: ${{ needs.plan.outputs.sync_python_deps }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run tests in base image
|
||||
env:
|
||||
GITHUB_WORKSPACE: ${{ github.workspace }}
|
||||
run: bash scripts/ci_bootstrap_backend.sh "${{ matrix.label }}" -v2
|
||||
31
.github/workflows/issue-template-check.yml
vendored
Normal file
31
.github/workflows/issue-template-check.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Do the actual check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: issue
|
||||
required-type: "Bug, Feature"
|
||||
enforcement: close-and-lock
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## Issue not opened from a template
|
||||
|
||||
|
||||
This issue was closed because it was not opened using one of the available issue templates.
|
||||
|
||||
Please [open a new issue]({new-issue-url}) and select the appropriate template. This helps us triage and address issues efficiently.
|
||||
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Delete old bot comments before posting fresh ones
|
||||
- uses: Dispatcharr/repo-bot/actions/comment-collapse@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
mode: delete
|
||||
|
||||
# Template + Agreement Check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: pull_request
|
||||
required-markers: "## How was it tested?, ## Checklist, - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## PR requirements not met
|
||||
|
||||
|
||||
Your PR description is missing one or more required sections. Please ensure all of the following are present and filled out exactly as they appear in the [PR template](../blob/dev/.github/pull_request_template.md).:
|
||||
|
||||
- **How was it tested?** Heading (describe how you verified your changes)
|
||||
- **Checklist** Heading (completed from the [pull request template](../blob/dev/.github/pull_request_template.md))
|
||||
- **Contributor License Agreement** Checklist Item (the following item must appear checked in your description):
|
||||
|
||||
> - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)
|
||||
|
||||
|
||||
Edit your PR description to add any missing items. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) for full contribution guidelines. Pull requests that do not follow the template, or that are wholly AI-generated or CLI-created, will be closed.
|
||||
|
||||
# Target Check
|
||||
- uses: Dispatcharr/repo-bot/actions/branch-guard@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
allowed-targets: "dev"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
comment: |
|
||||
## Wrong target branch
|
||||
|
||||
|
||||
This PR targets `{target-branch}`, but all contributions must target the `dev` branch.
|
||||
|
||||
> **To fix this:**
|
||||
> 1. Open the PR and click **Edit** next to the title
|
||||
> 2. Change the base branch from `{target-branch}` to `dev`
|
||||
> 3. Save the change
|
||||
|
||||
|
||||
Unsure about our contribution guidelines? See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md).
|
||||
590
CHANGELOG.md
590
CHANGELOG.md
|
|
@ -7,6 +7,596 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.27.2] - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now:
|
||||
- **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup.
|
||||
- **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case).
|
||||
- **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet.
|
||||
- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced).
|
||||
- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**.
|
||||
|
||||
## [0.27.1] - 2026-06-25
|
||||
|
||||
### Security
|
||||
|
||||
- Updated `Django` 6.0.5 → 6.0.6, resolving the following CVEs:
|
||||
- **CVE-2026-6873**: Signed cookie salt namespace collision in `HttpRequest.get_signed_cookie()`.
|
||||
- **CVE-2026-7666**: Potential unencrypted email transmission via STARTTLS in the SMTP backend.
|
||||
- **CVE-2026-8404**: Potential private data exposure via case-sensitive `Cache-Control` directives in `UpdateCacheMiddleware`.
|
||||
- **CVE-2026-35193**: Potential private data exposure via missing `Vary: Authorization` in `UpdateCacheMiddleware`.
|
||||
- **CVE-2026-48587**: Potential private data exposure via whitespace padding in the `Vary` header.
|
||||
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (1 low, 2 moderate, 1 high):
|
||||
- Updated `vite` 7.3.2 → 7.3.5, resolving **moderate** NTLMv2 hash disclosure via UNC path handling on Windows ([GHSA-v6wh-96g9-6wx3](https://github.com/advisories/GHSA-v6wh-96g9-6wx3)) and **high** `server.fs.deny` bypass on Windows alternate paths ([GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff))
|
||||
- Updated `js-yaml` 4.1.1 → 5.1.0, resolving **moderate** quadratic-complexity DoS in merge key handling via repeated aliases ([GHSA-h67p-54hq-rp68](https://github.com/advisories/GHSA-h67p-54hq-rp68))
|
||||
- Updated `esbuild` 0.27.3 → 0.28.1, resolving **low** arbitrary file read when running the development server on Windows ([GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr))
|
||||
|
||||
### Added
|
||||
|
||||
- **Isolated backend test settings (`dispatcharr.settings_test`).** `python manage.py test` now switches to this module automatically (via `manage.py`). It creates an empty PostgreSQL `test_<dbname>` database (same engine as production), uses the standard Postgres backend instead of geventpool so `TestCase` transactions isolate correctly, and leaves Celery tasks queued (no eager `post_save` signal runs during tests). Set `TEST_USE_SQLITE=1` for an in-memory SQLite fallback when Postgres is unavailable.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`get_vod_streams` and `get_series` XC API endpoints are faster and no longer exhaust Docker `/dev/shm`.** Large libraries (e.g. 125k movies) previously ran one wide `DISTINCT ON` query with parallel workers, which could fail with `could not resize shared memory segment … No space left on device` on the default 64MB container shm. Both endpoints now fetch display columns via `.values()` (no ORM model instantiation per row). Redundant `category` and `logo` joins were dropped in favor of FK ids; alphabetical sort runs in SQL. Typical full-library response time drops from ~23–28s to ~8–10s with stable shm usage.
|
||||
- **XMLTV EPG export is faster and no longer balloons worker memory.** `generate_epg()` was reworked end-to-end for large guides. (Fixes #1366)
|
||||
- Streams incrementally: on a cache miss each chunk is pushed to a Redis list as it is yielded (no `''.join()` in the worker); repeat requests within 300s stream chunks back from Redis. `malloc_trim` runs after cold builds.
|
||||
- Channel streams are prefetched once (only `id`/`name`) instead of one query per custom-dummy channel; dummy `EPGData` programme existence is bulk-checked in a single query.
|
||||
- The primary channel id is escaped once per `epg_id` group instead of once per programme (~750k fewer `html.escape` calls on a large guide).
|
||||
- The channel query no longer JOINs multi-MB `programme_index` blobs per channel (~13s saved on a ~2000-channel guide; indices live in `EPGSourceIndex`).
|
||||
- Programme export uses `(epg_id, id)` keyset pagination with a per-source `start_time` sort; a matching composite index on `ProgramData` (created `CONCURRENTLY` on PostgreSQL) lets each chunk use an ordered index range scan instead of re-sorting every chunk.
|
||||
- **EPG grid endpoint releases its payload memory back to the OS.** `/api/epg/grid/` drops the redundant full-list copy when appending dummy programmes and runs `malloc_trim` once the response is sent, so worker RSS no longer ratchets up ~20MB per request.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`programme_index` moved off `EPGSource` into a dedicated `EPGSourceIndex` table.** The multi-MB byte-offset index was repeatedly pulled into web and Celery workers by ordinary `EPGSource` queries and `select_related` JOINs (which ignore manager-level `defer()`). It now lives in a one-to-one `EPGSourceIndex` row, read only when explicitly accessed through the `EPGSource.programme_index` property, so no list, detail, or JOIN query can load it by accident. Migration `0026` copies existing indices across. No API or index-build behavior change.
|
||||
|
||||
- **EPG generation extracted into `apps/output/epg.py`.** All XMLTV output logic (`generate_epg`, `generate_dummy_programs`, `generate_custom_dummy_programs`, `generate_dummy_epg`, and supporting helpers) moved from `apps/output/views.py` into a dedicated module. `views.py` retains the thin HTTP endpoint wrappers and auth checks; `epg.py` handles all content generation. No behavior change.
|
||||
|
||||
- **Channel list with nested streams loads faster.** `GET /api/channels/channels/?include_streams=true` (Channels UI and single-channel fetch) now builds nested stream payloads from the prefetched `channelstream_set` instead of issuing one extra streams M2M query per channel.
|
||||
|
||||
- Dependency updates:
|
||||
- `Django` 6.0.5 → 6.0.6 (security patch; see Security section)
|
||||
- `requests` 2.33.1 → 2.34.2
|
||||
- `gevent` 26.4.0 → 26.5.0
|
||||
- `torch` 2.11.0+cpu → 2.12.1+cpu
|
||||
- `sentence-transformers` 5.4.1 → 5.6.0
|
||||
- `lxml` 6.1.0 → 6.1.1
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Channel Initialization Grace Period is honoured during live stream startup.** Preview and playback no longer abort after a hardcoded 10s while the channel is still connecting with an empty buffer; the TS generator init-wait stall check and upstream health monitor now use the configured `channel_init_grace_period` (same as the server cleanup watchdog) instead of `CONNECTION_TIMEOUT`. (Fixes #1380)
|
||||
- **Channels are marked `active` as soon as the buffer threshold is met and a client is streaming.** Once the initial buffer fills, state is set to `active` immediately when viewers are attached, or `waiting_for_clients` when the buffer is ready but no client is connected yet (e.g. proxy API warmup). The cleanup watchdog no longer waits an extra grace period before promoting to `active`. Proxy settings copy now describes `channel_init_grace_period` as an initialization buffer timeout.
|
||||
- **DVR recording playback auth is complete for native video, HLS segments, and redirects.** Completed recordings use `/file/` with native `<video src>`, which cannot send `Authorization` headers. Playback accepts `?token=` query-param JWT (matching VOD streams), and the floating video player appends the token when assigning native URLs. The explicit HLS URL route (`/api/channels/recordings/.../hls/...`) now registers the same authenticators as the ViewSet `@action` handlers—previously only header JWT applied on that path, so `?token=` failed for native HLS clients. When the request used `?token=`, rewritten playlist segment URLs and `/file/`↔`/hls/` redirects preserve the token; hls.js clients that authenticate via `Authorization` are unchanged. `QueryParamJWTAuthentication` reads `request.query_params` on DRF requests.
|
||||
- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329)
|
||||
- **`refresh_single_m3u_account` no longer re-raises after setting account ERROR.** Matches `refresh_epg_data`: the account status and UI already reflect the failure; Celery no longer marks the task FAILED after the terminal error state is persisted.
|
||||
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
|
||||
- **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch.
|
||||
- **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values.
|
||||
- **`POST /api/epg/import/` no longer loads the full EPG source row.** The dummy-source guard uses a narrow existence query instead of `objects.get()`, keeping the import trigger lightweight. Request/response shape is unchanged for the UI, plugins, and external importers.
|
||||
- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363)
|
||||
- **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups.
|
||||
- **OpenAPI schema paths for nested M3U profiles and filters no longer contain escaped slashes.** Router regex prefixes used unnecessary `\/`, which drf-spectacular serialized literally into the schema and broke client generators (e.g. oapi-codegen). Runtime URL matching is unchanged. (Fixes #1384)
|
||||
- **Auto-sync range conflict warning no longer flags a group's own channels when using a channel-group override.** The M3U group settings "Range conflict" check compared occupant channel groups against the source group being configured. Auto-sync with `group_override` creates channels in the override target group, so those channels were misclassified as conflicts. The frontend now resolves the effective target group (override target when set, otherwise the source group) for classification. Genuine conflicts—manual channels, other accounts, different groups, or user-pinned numbers—still surface. (Fixes #1331) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.27.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Manual Server Groups for shared M3U connection limits.** The existing `ServerGroup` model and account FK are now wired into live and VOD playback. Accounts assigned to the same group share a credential-scoped Redis counter when their provider logins match (hashed fingerprint); unrelated logins in the same group keep separate counters. Enforcement uses each profile's `max_streams` - not a group-wide cap - and profiles with `max_streams=0` skip credential pooling for that profile while still rotating on their own per-profile counter. New `apps/m3u/connection_pool.py` centralizes reserve/release, profile rotation, and credential moves on profile switch. (Closes #1137) — Thanks [@Goldenfreddy0703](https://github.com/Goldenfreddy0703)
|
||||
- **Server Groups manager UI** on the M3U Accounts page: create, rename, and delete groups with account counts and delete confirmation. Groups load on login via a new Zustand store and REST helpers (`/api/m3u/server-groups/`).
|
||||
- **M3U account form** adds a Server Group picker (including inline “add group”), reorganized into three columns (source/auth, connection limits, sync/content), and a Manage server groups shortcut.
|
||||
- **VOD profile selection** uses `pool_has_capacity_for_profile()` so grouped accounts respect shared credential limits before a profile is chosen (non-grouped accounts behave as before).
|
||||
- **Live profile switches** move the shared credential counter when the new profile uses a different provider login; same-login switches leave the credential counter unchanged.
|
||||
- **Credential release keys** stored at reserve time allow counters to be released even if the M3U profile row is deleted afterward.
|
||||
- **PostgreSQL `application_name` tagging by process role.** Pool connections now set `application_name` at connect time (e.g. `Dispatcharr-uwsgi-{pid}`, `Dispatcharr-celery-worker-{pid}`, `Dispatcharr-celery-dvr-{pid}`) so `pg_stat_activity` shows which Dispatcharr process owns each backend instead of a generic client label.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Plugin repo manifests support split download and metadata base URLs.** `download_base_url` and `metadata_base_url` are optional alternatives to `root_url` in the plugin repository manifest, so repo authors can serve metadata (manifests, icons) and release zips from different origins without absolute URLs everywhere. Manifest and icon URLs resolve via `metadata_base_url` then `root_url`; latest/download URLs resolve via `download_base_url` then `root_url`. When `metadata_base_url` is set and a plugin entry has `manifest_url` but no `icon_url`, the icon defaults to `logo.png` in the same directory as the per-plugin manifest. Manifests using only `root_url` behave identically to before. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Live and VOD connection slot logic now routes through `connection_pool`.** `Channel.get_stream()`, direct `Stream.get_stream()`, VOD profile selection, and the multi-worker VOD connection manager all call `reserve_profile_slot()` / `release_profile_slot()` instead of inline Redis INCR/DECR. VOD profile selection also checks `pool_has_capacity_for_profile()` before choosing a profile.
|
||||
- **Live XC upstream URLs use current credentials.** `_resolve_live_stream_url()` builds `/live/{user}/{pass}/{stream_id}.ts` from transformed account credentials and the stream's provider `stream_id`, so playback stays aligned with the active login after credential or profile changes instead of reusing a stale `stream.url` from sync.
|
||||
- **Channel stream switches check pooled capacity.** `get_stream_info_for_switch()` uses `profile_available_for_channel_switch()` when targeting a specific stream, and releases a reserved slot if URL assembly fails after `get_stream()` allocated one.
|
||||
- **Live proxy init reads Redis assignment once.** After `generate_stream_url()` reserves a slot, the stream handler reads `channel_stream` / `stream_profile` from Redis instead of calling `get_stream()` again, avoiding double INCR under concurrent release.
|
||||
- **Centralized Dispatcharr User-Agent construction in `core.utils`.** Outbound HTTP calls (Schedules Direct API, update checks, logo/VOD fallbacks, DVR recording clients) now use `dispatcharr_user_agent()`, `dispatcharr_dvr_user_agent()`, and `dispatcharr_http_headers()` instead of ad-hoc `Dispatcharr/{version}` strings and stale `Dispatcharr/1.0` fallbacks.
|
||||
- **EPG auto-match overhaul** — matching logic moved to `apps/channels/epg_matching.py`; Celery tasks in `tasks.py` are thin wrappers.
|
||||
- Single-channel auto-match is now asynchronous: the API returns `202 Accepted` and pushes the result over WebSocket (`single_channel_epg_match`), so large EPG libraries no longer hit the previous 30-second HTTP timeout.
|
||||
- Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers.
|
||||
- Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG.
|
||||
- Rematching to the same EPG no longer re-saves the channel or queues program-parse tasks; only assignments that actually change are written and refreshed.
|
||||
- **Easier EPG search when editing a channel.** The filter in the EPG picker now works more like a normal search box. You can type several words at once (e.g. `sky uk`) and it finds channels where every word appears somewhere in the name or TVG-ID, the order doesn't matter, and a word in the name can pair with one in the ID. Accents are ignored too, so `decale` matches `Décalé` and the other way around. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
|
||||
|
||||
### Performance
|
||||
|
||||
- **XC live refresh releases bulk catalog data sooner during batch processing.** After filtering the single `get_all_live_streams` response, the full provider catalog list is dropped before batch DB work. `process_m3u_batch_direct` (the path XC refreshes use) now runs `gc.collect()` after each batch and clears batch slice references as thread futures complete. Large structures are still `del`'d in the refresh `finally` block before Celery's `task_postrun` runs `cleanup_memory()`.
|
||||
- **VOD movie/series batch matching no longer scans the full no-ID catalog.** `process_movie_batch` and `process_series_batch` previously loaded every `Movie`/`Series` row without TMDB or IMDB IDs on each 1000-item chunk to resolve name+year duplicates. Lookup is now scoped to the names in the current batch via `lookup_by_name_year()`, which reduces memory and DB time per chunk. `refresh_vod_content` and `batch_refresh_series_episodes` are registered for Celery post-task memory cleanup (one GC pass at task end, not per chunk).
|
||||
- **EPG programme parse now streams through PostgreSQL staging instead of holding the full catalogue in Python.** `parse_programs_for_source` writes parsed rows into a session-scoped temp table in batches (`_EPG_PARSE_BATCH_SIZE=2500`), then atomically swaps them into `ProgramData` with batched `DELETE ... RETURNING` + `INSERT` (`_EPG_SWAP_BATCH_SIZE=5000`) so Postgres never materializes the entire guide in one statement. Peak Celery memory during large XMLTV refreshes drops sharply compared with building a monolithic in-memory list before bulk insert. The byte-offset programme index build is deferred until after the swap completes so index construction no longer competes with parse for memory and I/O. `refresh_epg_data` closes its DB connection in `finally`, and `build_programme_index_task` is registered as a memory-intensive Celery task. SQLite/dev installs keep an in-memory fallback path.
|
||||
- **Pooled PostgreSQL connections now rotate on a bounded lifetime.** psycopg3 client-side cache grows on handles kept open indefinitely by `django-db-geventpool`; recycling uWSGI workers would interrupt live streams. A thin custom backend (`dispatcharr.db.backends.postgresql_psycopg3`) closes and replaces pool connections after `DATABASE_POOL_CONN_MAX_LIFETIME` seconds (default 600, env-overridable; set `0` to disable) on checkout and return while preserving warm-pool reuse within the window. (Fixes #1343)
|
||||
- **Schedules Direct refresh only fetches guide data for mapped channels.** Schedule MD5 checks and schedule downloads now target mapped lineup stations instead of the entire lineup, and schedule MD5 cache for unmapped stations is pruned each refresh. Unmapped lineup entries no longer trigger wasted schedule API calls when their MD5 changes.
|
||||
- **EPG auto-match memory and throughput improvements.**
|
||||
- Single-channel matching streams active EPG rows and keeps only the best match plus the top 20 candidates in memory; ML validates at most 21 names per channel instead of embedding the full catalog.
|
||||
- Strong fuzzy matches (≥75% single channel, ≥80% bulk) skip ML entirely, avoiding a ~500MB PyTorch load when the fuzzy result is already reliable.
|
||||
- Bulk matching uses a single fuzzy pass per channel instead of scanning the full catalog twice for best match and top candidates.
|
||||
- Bulk exact `tvg_id` / Gracenote matching uses an in-memory index built alongside the EPG catalog (`build_epg_matching_catalog()`), giving O(1) lookups with no extra database queries.
|
||||
- Bulk match apply uses batched queries (two fetches plus `bulk_update`) instead of one `EPGData.objects.get()` per matched channel.
|
||||
- EPG normalization settings are cached once per matching run, avoiding repeated `CoreSettings` reads when normalizing thousands of names.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Live proxy channels could remain running with no clients after disconnect.** `stop_channel` called `log_system_event('channel_stop')` synchronously before local cleanup and Redis key deletion; Connect integrations or DB event writes on that path could block teardown indefinitely while the cleanup thread kept refreshing metadata TTL for a stale `stream_buffers` entry. Teardown now signals shutdown (`buffer.stopping`), runs model `release_stream()` and Redis key deletion, releases ownership, stops ffmpeg/output managers and local buffers, and only then logs the stop event asynchronously. Metadata TTL refresh skips channels mid-shutdown and non-owned channels; a stuck-stop watchdog forces cleanup if `stop_channel` does not return within ~10s (or 2× `channel_shutdown_delay`, whichever is greater). Stream buffers ignore late `add_chunk()` writes once shutdown begins (`buffer.stopping` set at teardown start).
|
||||
- **Client reconnect during channel teardown could wedge the channel across uWSGI workers.** The disconnect path called `proxy_server.stop_channel()` directly, so other workers were not notified and reconnecting clients could attach while ownership was released on the owner worker. The cleanup thread then re-acquired expired ownership and looped on orphaned clients while the upstream connection stayed open. All lifecycle stops now go through coordinated Redis teardown (`channel_stopping` flag, metadata `stopping` state, and `CHANNEL_STOP` pubsub). New stream requests are rejected with `503 Retry-After` only during active teardown (not during the post-disconnect shutdown delay grace period); `initialize_channel()` and ownership re-acquisition refuse to proceed in those states; non-owner workers clean local resources only on pubsub; and orphaned-client sweeps force cleanup when teardown is already active. (Fixes #1342)
|
||||
- **Orphaned upstream threads kept writing Redis after teardown.** Partial cleanup could remove a channel from `stream_managers` while the `stream-{uuid}` OS thread kept running, so orphan metadata sweeps only deleted Redis keys and chunks/metadata were immediately recreated (bare `total_bytes` hash with TTL -1). Stream managers now stop upstream when ownership is lost, orphan cleanup stops local ffmpeg/stream threads even when registry entries are gone (`_live_stream_managers` tracks managers until the thread exits), and forced recovery stops processes before Redis deletion instead of dropping dict entries without calling `stop()`.
|
||||
- **Rapid channel switching could leave bare `buffer:index` keys in Redis.** `buffer.stop()` flushed a final partial chunk via `INCR` during teardown, creating a persistent index key (TTL -1) after chunk keys expired; under load overlapping disconnect and cleanup-thread stops could race on `_stop_local_stream_activity`, blocking on ffmpeg stderr join before `_clean_redis_keys` ever ran. Teardown no longer writes to Redis from `buffer.stop()`, disconnect uses coordinated stop only (no duplicate upstream stop), model `release_stream()` runs before Redis keys are scanned/deleted (so `profile_connections` counters are not left stuck), ownership is released after Redis cleanup but before the blocking local ffmpeg stop, and a `finally` block guarantees Redis cleanup if local teardown raises.
|
||||
- **Live proxy could leak geventpool DB checkouts outside HTTP requests.** With `django-db-geventpool`, `connection.close()` returns handles to the per-worker pool (`MAX_CONNS=8`); without it, greenlets and OS threads keep connections checked out until the pool blocks on `pool.get()`. Proxy paths that touch the ORM outside Django's request cycle now call `close_old_connections()` after work completes: `_clean_redis_keys()`, profile lookup in `_establish_transcode_connection()` before spawning ffmpeg, and fMP4 client disconnect cleanup when releasing streams (TS disconnect relies on `log_system_event()`). (Fixes #1345)
|
||||
- **System events could block callers on Connect and plugin handlers.** `log_system_event()` ran Connect subscriptions and plugin `"events"` hooks synchronously after the DB write, so slow integrations could stall live-proxy and streaming paths even when inserting the event row was fast. Connect/plugin dispatch now runs on a separate gevent when the hub is available (synchronously in Celery workers without a hub). `log_system_event()` and `dispatch_event_system()` also call `close_old_connections()` in `finally` blocks so integration work does not leave pool slots checked out on the caller or dispatch greenlet.
|
||||
- **Plugins could leak geventpool DB checkouts after UI or Connect event runs.** Third-party plugins run inline on uWSGI greenlets (manual actions and Connect `"events"` hooks) with no guaranteed connection cleanup at the plugin boundary. `PluginManager.run_action()` and `stop_plugin()` now call `close_old_connections()` in a `finally` block so each action returns its pool slot whether the plugin succeeds or raises.
|
||||
- **Connect events repeatedly queried the full plugin catalog during streaming.** `trigger_event()` called `list_plugins()` on every `client_connect` / `client_disconnect`, loading all `PluginConfig` rows and sometimes hitting plugin-repo work even when no plugin subscribed to the event. Dispatch now walks the in-memory registry via `iter_actions_for_event()`, returns immediately when no handlers exist, and runs a single batched `enabled` lookup for matching plugins only. Failed plugin actions are logged per handler instead of aborting the rest.
|
||||
- **Plugin discovery left idle Postgres backends after worker boot.** `discover_plugins()` runs outside Django's request cycle during uWSGI and Celery startup; it now calls `close_old_connections()` in a `finally` block so bootstrap `PluginConfig` queries return their pool slot instead of appearing as long-lived idle connections in `pg_stat_activity`.
|
||||
- **VOD proxy could leak geventpool DB checkouts during playback and stats updates.** `stream_vod()` ran ORM lookups for content and M3U profiles, then returned a long-lived `StreamingHttpResponse` without releasing the checkout, so each movie/episode stream could hold a pool slot for its full duration. Background VOD stats refresh (`build_vod_stats_data()`, triggered on start/stop and by the admin stats API) also queried movie, episode, and profile rows from daemon threads with no cleanup. `stream_vod()` now calls `close_old_connections()` before handing off to the streaming generator, and `build_vod_stats_data()` releases its checkout in a `finally` block.
|
||||
- **Channel shutdown delay did not reset after a reconnect within the grace period.** `handle_client_disconnect()` used a fixed `gevent.sleep(shutdown_delay)` from the first last-client disconnect. If a client reconnected and disconnected again during the delay, an earlier disconnect handler could still stop the channel on the original timer instead of waiting the full delay from the latest disconnect. Shutdown now polls Redis (`last_client_disconnect` timestamp and client count) so concurrent disconnect handlers and multi-worker reconnects always honour the latest disconnect time.
|
||||
- **Zombie ffmpeg could survive owner-lock expiry and orphan Redis sweeps.** When the 30s owner lock lapsed under single-worker load, disconnect handling treated the worker as non-owner so coordinated stop never ran, while ffmpeg kept writing and the orphan sweeper only deleted Redis keys (recreating them immediately). Disconnect now re-acquires ownership when local upstream is still active, stops locally when the last client leaves without a lock, orphan cleanup stops local ffmpeg before Redis deletion via `_has_local_upstream_activity`, re-init stops lingering upstream before starting a duplicate thread, and `is_channel_teardown_active` includes channels mid-`stop_channel` on this worker so rapid reconnect gets 503 during teardown.
|
||||
- **Stale `channel_stream` Redis keys after a channel stopped could skip connection accounting on retune.** On `dev`, `get_stream()` reused any existing `channel_stream` / `stream_profile` assignment without reserving a new slot. If those keys were left behind after a stop, the next tune-in could reach the provider without incrementing Redis counters. `get_stream()` now releases stale assignments when proxy metadata shows the channel is inactive, then reserves fresh slots.
|
||||
- **EPG auto-match reliability fixes.**
|
||||
- Memory could spike to multiple GB on large EPG sources when building a full in-memory catalog before fuzzy matching; single-channel matching now streams rows and bounds ML work to a small candidate set.
|
||||
- Wrong channel assignments from global ML similarity; ML validation now checks the fuzzy best match (or top fuzzy candidates as a last resort) instead of scoring the entire catalog.
|
||||
- Channel form auto-match spinner could stick after errors or early task exits; all single-channel outcomes now push a WebSocket result, and the UI clears loading state after a 3-minute timeout.
|
||||
- Bulk auto-match completion no longer calls `batch-set-epg` from the WebSocket handler, which had been re-applying every match and queueing redundant `parse_programs_for_tvg_id` tasks even when assignments were unchanged.
|
||||
- **Schedules Direct lineup search country dropdown.** The country list was fetched directly from the SD API in the browser, which failed due to CORS and silently fell back to a 14-country hardcoded list. Countries are now included in the `GET sd-lineups` response (server-side fetch with proper User-Agent) so the full SD country list populates correctly. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Schedules Direct program poster proxy omitted User-Agent on image requests.** The poster endpoint authenticated with SD correctly but fetched images with only the `token` header, which violates SD's API requirements (error 1003). Image requests now include the standard `Dispatcharr/{version}` User-Agent via `dispatcharr_http_headers()`.
|
||||
- **Schedules Direct guide data missing after mapping a channel.** Lineup refreshes cached schedule MD5s for all stations, but `ProgramData` was only written for mapped channels. Mapping a channel later could leave MD5s looking unchanged so schedules were skipped and the channel had no guide until dates rolled outside the cached window. Refreshes now backfill fetch-window dates that lack `ProgramData` (including newly mapped stations with stale cache), fetch program metadata when no local `ProgramData` exists for a `programID`, and clean up orphaned `ProgramData` on unmapped `EPGData` entries. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Schedules Direct guide fetch on channel map.** Mapping a channel (or bulk-assigning EPG) now triggers a targeted guide fetch for that `EPGData` entry—mirroring XMLTV's `parse_programs_for_tvg_id` flow—without waiting for the next full source refresh. Skips the fetch when `ProgramData` already exists so additional channels sharing the same `tvg-id` do not trigger redundant API calls. Bulk assignment of three or more SD stations without guide data on the same source queues one batched mapped-station fetch instead of separate per-station API sessions. Concurrent batch and single-EPG fetches coordinate via source-level locks with deferred retries so mappings are not dropped and overlapping SD API sessions are avoided when possible.
|
||||
- **Auto-sync numbering modes now read only the fields each mode's UI exposes.** After the auto-sync overhaul, switching between Provider, Next Available, and Fixed modes left stale `auto_sync_channel_start` / `auto_sync_channel_end` values in the database while each mode's UI only edits a subset of those fields. Sync treated the hidden values as authoritative in every mode, which discarded valid provider numbers (floored at an auto-computed start), capped Next Available at a stale End, and ran range-enforcement deletes against provider- and next-available-numbered channels. Provider mode now honors `stream_chno` verbatim when free (Start/End bound only the fallback for numberless streams); Next Available ignores End; overflow-delete runs in Fixed mode only. Duplicate or already-taken provider numbers fall back to a free slot instead of being dropped or overwriting an existing channel. Provider mode's Start # field now drops End when it would invert the fallback range (matching Fixed mode). (Closes #1273) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.26.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- **EPG logo auto-apply for XMLTV and Schedules Direct.** Channel logos are applied from `EPGData.icon_url` through a shared helper used by bulk "Set Logos from EPG", optional per-source auto-apply on refresh (`auto_apply_epg_logos` in source `custom_properties`), and a new `epg_source_id` option on the set-logos-from-epg API. Large libraries are processed in 500-channel chunks without loading every mapped row into memory.
|
||||
- **Schedules Direct EPG integration.** Dispatcharr now supports Schedules Direct as a first-class EPG source type alongside XMLTV and dummy EPG, with credential auth, lineup management, and guide refresh through the existing EPG pipeline and WebSocket progress. (Closes #1246) — Thanks [@Shokkstokk](https://github.com/Shokkstokk)
|
||||
- Lineup manager in EPG source settings: search by postal code, add/remove up to four active lineups, with SD's six-adds-per-24-hours limit and midnight-UTC reset surfaced in the UI.
|
||||
- MD5 delta refresh skips unchanged schedule and program downloads; a two-hour minimum interval between full refreshes is enforced (bypassable via force refresh).
|
||||
- Station logos in dark, light, gray, or white variants (`logo_style`), stored in `EPGData.icon_url` alongside XMLTV channel icons.
|
||||
- Optional program poster fetch (off by default): configurable style preference (SD Recommended via Gracenote's `primary` flag, or portrait/landscape banner/iconic variants with fallbacks), served through a backend proxy cached by nginx on first view.
|
||||
- XMLTV-compatible cast output (`role` for character names, `guest` for guest stars).
|
||||
- Lightweight stations-only fetch on source creation so EPG entries exist for auto-matching before the first full schedule pull.
|
||||
- **Live EPG program preview in the channel create/edit modal.** When selecting an EPG channel in the channel form, a "Current Program" card appears showing the currently-airing program title, description, and a progress bar. The backend builds a byte-offset index over the raw XMLTV file after each EPG refresh so lookups seek directly to the relevant file positions rather than parsing the full file - returning results in 1-10ms regardless of source size. If the index has not yet been built for a source, the API dispatches an async background build and the frontend retries up to 20 times over a 3-minute window. The `ProgramPreview` component was extracted into a shared component reused by the Stats page. The `programme_index` field on `EPGSource` is excluded from all API list responses to avoid returning multi-MB blobs. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
|
||||
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`send_websocket_update` now detects Celery worker context when gevent is monkey-patched.** EPG refresh progress (including Schedules Direct) uses the shared `send_epg_update` helper instead of a duplicate synchronous Redis sender. In Celery prefork workers that inherit gevent patching, WebSocket messages are delivered synchronously via the existing `_gevent_ws_send` Redis path; uWSGI continues to use `gevent.spawn` as before.
|
||||
- **Schedules Direct EPG delete confirmation shows username only.** The delete dialog no longer surfaces password or API key fields for credential-based SD sources.
|
||||
- **Channel edit form reorganized into three semantic columns.** Fields are now grouped as Identity (name, number, group, logo), Guide Data (TVG-ID, Gracenote StationId, EPG picker, current program preview), and Behavior/Access (stream profile, user level, mature content, hidden). The EPG section having its own column gives the `ProgramPreview` card enough width to truncate long titles correctly rather than expanding the column.
|
||||
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **nginx logo/poster proxy cache moved to `/data/logo_cache`.** `proxy_cache_path` now uses the persistent data volume instead of `/app/logo_cache`, and the init script ensures the directory exists with correct ownership on container start.
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
|
||||
- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac)
|
||||
- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg[binary]` (psycopg3), which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime.
|
||||
- **Database driver upgraded from `psycopg2` to `psycopg3` (`psycopg[binary]`).** psycopg3 rewrote its network I/O layer in Python, so `gevent`'s `monkey.patch_all()` makes it gevent-cooperative without any additional patching. The `psycogreen` dependency and its driver-patching block in `gevent_patch.py` have been removed. Django's native psycopg3 connection pool is explicitly disabled (`pool: False`) so `django-db-geventpool` remains in sole control of connection lifecycle. The `dropdb` management command was updated to the `psycopg` API (`psycopg.connect`, `psycopg.sql`).
|
||||
- **Frontend unit tests extended to additional form components and the Guide page.** `ProgramRecordingModal`, `Recording`, `RecordingDetailsModal`, `RecurringRuleModal`, `ScheduleInput`, `SeriesRecordingModal`, `SeriesRuleEditorModal`, `Stream`, `StreamProfile`, `SuperuserForm`, `User`, `UserAgent`, and `VODCategoryFilter` now have Vitest + Testing Library test suites. Business logic was extracted from these components into corresponding utility modules and is exercised through the new tests. The Guide page was similarly refactored with extracted utils, and `dateTimeUtils.js` was extended to support the new test coverage. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Official plugin repository manifest URL moved to GitHub Pages.** The default `OFFICIAL_REPO_URL` now points to `https://dispatcharr.github.io/Plugins/manifest.json` instead of the raw GitHub content URL. GitHub Pages avoids opaque caching on raw content that could serve stale manifests without indication. A data migration updates existing `PluginRepo` rows marked `is_official=True` in place; fresh installs pick up the new URL from the model default after the seed migration runs. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
|
||||
### Performance
|
||||
|
||||
- **M3U and EPG channel logo URLs no longer call `build_absolute_uri_with_port()` per channel.** The host/port/scheme base URL and logo path prefix/suffix are computed once per request; each row appends only the logo ID. M3U proxy stream URLs use the same pattern for channel UUIDs.
|
||||
- **`get_vod_streams` and `get_series` XC API response times reduced significantly for large libraries.** Both endpoints previously loaded all active relations per title (one row per account that carries the same movie/series), then discarded duplicates in Python. With large libraries across multiple active accounts this fetched a multiple of N rows. Both queries now use a single `DISTINCT ON (movie_id/series_id)` pass over the relation table ordered by account priority, returning exactly one row per title. Logo URL construction (`reverse()` + `build_absolute_uri_with_port()`) is also computed once and reused via prefix/suffix string concatenation, matching the existing pattern in `get_live_streams`. The `m3u_account` JOIN in `get_series` was also removed (it was fetched but never read in the loop). Additionally, `get_series` previously had no `order_by` on `M3USeriesRelation`, so output order was non-deterministic (physical storage order). Both endpoints now sort alphabetically by title. Observed improvements: 18s → 12s for 48k movies; 9.5s → 3.5s for 11k series.
|
||||
- **Database connections are now managed by a persistent per-worker pool.** Previously each request opened a fresh TCP connection to PostgreSQL, paid a full authentication handshake, and closed the connection at request end. `django-db-geventpool` now maintains a pool of warm connections per uWSGI worker; requests borrow a connection and return it when done, eliminating connection-setup overhead on every request. Pool size is bounded (`MAX_CONNS=8` per worker, `REUSE_CONNS=3` warm connections kept idle) to stay comfortably within PostgreSQL's default `max_connections=100` across all uWSGI workers, Celery workers, and Daphne. — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
|
||||
### Security
|
||||
|
||||
- **M3U endpoint no longer reflects POST body content in error responses.** The error message for disallowed POST requests previously echoed the raw request body back to the caller in a `text/html` response, which could be used for reflected XSS. The body is no longer included in the response. - Thanks [@sebastiondev](https://github.com/sebastiondev)
|
||||
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (2 moderate, 2 high, 1 critical):
|
||||
- Updated `react-router` and `react-router-dom` 7.13.0 → 7.17.0, resolving **high** unauthenticated RCE via turbo-stream TYPE_ERROR deserialization ([GHSA-49rj-9fvp-4h2h](https://github.com/advisories/GHSA-49rj-9fvp-4h2h)), **high** open redirect via protocol-relative `//` paths ([GHSA-2j2x-hqr9-3h42](https://github.com/advisories/GHSA-2j2x-hqr9-3h42)), **high** XSS in unstable RSC redirect handling via `javascript:` targets ([GHSA-8646-j5j9-6r62](https://github.com/advisories/GHSA-8646-j5j9-6r62)), **high** stored XSS via unescaped `Location` header in prerendered redirect HTML ([GHSA-f22v-gfqf-p8f3](https://github.com/advisories/GHSA-f22v-gfqf-p8f3)), **high** DoS via unbounded path expansion in the `__manifest` endpoint ([GHSA-8x6r-g9mw-2r78](https://github.com/advisories/GHSA-8x6r-g9mw-2r78)), and **high** DoS via reflected user input in single-fetch ([GHSA-rxv8-25v2-qmq8](https://github.com/advisories/GHSA-rxv8-25v2-qmq8))
|
||||
- Updated `vitest` 3.2.4 → 4.1.8, resolving **critical** arbitrary file read and execution when the Vitest UI server is listening ([GHSA-5xrq-8626-4rwp](https://github.com/advisories/GHSA-5xrq-8626-4rwp))
|
||||
- Updated `brace-expansion` 5.0.5 → 5.0.6, resolving **moderate** DoS when large numeric ranges defeat the documented `max` limit ([GHSA-jxxr-4gwj-5jf2](https://github.com/advisories/GHSA-jxxr-4gwj-5jf2))
|
||||
- Updated `ws` 8.19.0 → 8.21.0, resolving **moderate** uninitialized memory disclosure ([GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HDHR lineup and discovery URLs now use the shared port-aware URI builder.** `lineup.json`, `discover.json`, and `device.xml` previously called Django's `request.build_absolute_uri()`, which drops non-standard ports behind reverse proxies and on dev installs. They now use `build_absolute_uri_with_port()` from `core/utils.py`, matching M3U, EPG, XC, and logo cache URLs.
|
||||
- **EPG channel list did not refresh after a source finished parsing channels.** The `epg_refresh` WebSocket handler closed over a stale `epgs` snapshot from when the socket connected. When a newly created source was not found in that snapshot, the handler updated progress and returned early without calling `fetchEPGData()`, so the channel picker and EPG assignment UI stayed empty until a full page reload. The handler now reads the current store via `getState()` and still calls `fetchEPGData()` when `parsing_channels` completes.
|
||||
- **DVR recordings no longer stop immediately when FFmpeg exits mid-stream.** If FFmpeg crashes, stalls, or loses the source before the scheduled end time, `run_recording` now restarts it in-process instead of going straight to HLS→MKV concat and marking the recording complete. Each restart reuses the same HLS working directory and continues segment numbering (`append_list`, `-start_number`) so the final MKV is a single continuous file. Retries are bounded by a per-outage time window matching live-proxy client tolerance (`STREAM_TIMEOUT` + `FAILOVER_GRACE_PERIOD`, default 80s); the window resets whenever new segments resume, so a long recording can survive multiple separate outages. Reconnect pacing between attempts follows the same `min(0.25 × attempt, 3s)` backoff used by the live-proxy `StreamManager`. Input demuxing also uses `-err_detect ignore_err` to tolerate minor TS corruption without aborting the process. If the outage window expires without recovery, the recording is saved as `interrupted` with `ffmpeg_outage_window_exhausted` rather than falsely reported as `completed`. (Closes #1170)
|
||||
- **DVR HLS→MKV concat now tolerates timestamp splices and corrupt segments.** The finalize step (and startup recovery concat) previously used a bare `ffmpeg -f concat -c copy`, which could fail on truncated tail segments or PTS discontinuities from FFmpeg restarts mid-recording. Concat now uses a shared `_dvr_build_hls_concat_cmd` helper with `-fflags +genpts+igndts+discardcorrupt`, `-err_detect ignore_err`, and `-avoid_negative_ts make_zero`; the existing MP4-intermediate fallback path uses the same tolerant input flags.
|
||||
- **DVR FFmpeg restart no longer skips HLS segment numbers.** On restart, `-start_number` was set to the existing segment count while `append_list` also incremented the internal sequence for each segment reloaded from `index.m3u8`, so the first post-restart segment could land at roughly double the expected index (e.g. `seg_00028.ts` after 14 segments). Restarts now pass `-start_number 0` when the playlist already lists segments and let `append_list` continue numbering; loose `.ts` files without a playlist still seed from the highest filename index + 1.
|
||||
- **Null TS keepalive packets during channel initialization caused Emby (and Jellyfin) to force a deinterlace transcode or fail playback entirely.** While waiting for a channel to become ready, the generator sent a null TS packet (PID `0x1FFF`) every 0.5 s to keep the HTTP connection alive. Emby probes the initial bytes of the response via ffprobe; receiving a null packet instead of real stream data produced incorrect codec metadata and triggered a forced transcode. The null-packet yield has been replaced with a plain `gevent.sleep(0.1)`. (Fixes #1280)
|
||||
- **VOD proxy stream URLs survive M3U import refresh cycles.** When `process_movie_batch` / `process_series_batch` create duplicate content records during a refresh, existing `M3UMovieRelation` / `M3UEpisodeRelation` rows are repointed at the new records, orphaning the old UUIDs. External players (Emby, Jellyfin, ChannelsDVR) that cached `.strm` URLs with the dead UUID would then 404 even though the same request carried a stable `stream_id` that maps to an active relation. The proxy now falls back to stream_id resolution when the UUID lookup misses, using strictest-match-first logic (account-scoped relation preferred, then highest-priority account). A `[STREAMID-FALLBACK]` WARNING log keeps the underlying import churn. — Thanks [@R3XCHRIS](https://github.com/R3XCHRIS)
|
||||
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
|
||||
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Disabled state on the size-labeled install button rendered with a warm tint instead of appearing clearly disabled.** The CSS filter was `brightness(0.65) saturate(0.7)`, which left a faint color cast. It is now `grayscale(1) brightness(0.55)`, matching the standard disabled appearance. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.
|
||||
- **Cast and actors lists were silently truncated to the first name.** When a provider returns `cast` or `actors` as a JSON array, the helper used during both movie and series basic sync and movie advanced refresh only extracted the first element. All names in the array are now joined into a comma-separated string. Providers that return cast as a plain string are unaffected.
|
||||
- **Channel Group Override interactions in compact numbering and override display.** Two related bugs surfaced when Channel Group Override was used with auto-synced channels. First, compact numbering silently failed: with an override on the source `ChannelGroupM3UAccount`, sync stores channels under the override target group's id (not the source group id), so hide/unhide/repack operations all missed their channels and slot accounting broke silently (hidden channels kept their numbers, unhides got none, repack saw zero channels). The compact paths now include an override-aware fallback to match channels under both source and override-target group ids. Second, the clear-override reset button disappeared when an override's stored value coincidentally matched the provider value. The frontend `isFormFieldOverridden` function was value-based only; it now also checks for a persisted override row regardless of value, so the reset button remains available to clear any override. (Fixes #1263, Fixes #1276) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Compact numbering repack is now idempotent.** Auto-synced channel numbers reshuffled within their configured range on every sync, even when the provider returned no changes. The compact repack queried channels with no `ORDER BY`, so packing followed PostgreSQL's physical row order, which drifts after each repack's UPDATEs and autovacuum. The channel query now uses `.order_by("id")` (creation order tracks provider stream order for the default "provider" sort), and explicit name / tvg_id / updated_at sorts carry `c.id` as a secondary tiebreaker so equal values keep a stable relative order instead of churning. (Fixes #1321) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.25.1] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260)
|
||||
- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259)
|
||||
- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly.
|
||||
- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback.
|
||||
|
||||
### Performance
|
||||
|
||||
- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation.
|
||||
|
||||
## [0.25.0] - 2026-05-21
|
||||
|
||||
### Security
|
||||
|
||||
- Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs:
|
||||
- **CVE-2026-6907**: Cache leak exposing sensitive information via `cache.get_or_set()` race condition.
|
||||
- **CVE-2026-35192**: Persistent session cookies retaining sensitive information after logout.
|
||||
- **CVE-2026-5766**: Improper handling of length parameter inconsistency in multipart form parsing.
|
||||
|
||||
### Added
|
||||
|
||||
- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states.
|
||||
- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option:
|
||||
- **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut.
|
||||
- **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this.
|
||||
- **Comskip hardware acceleration setting.** DVR Settings now includes a "Hardware acceleration" option that passes a hardware-decode flag to the comskip binary, reducing CPU load during commercial detection on capable hosts:
|
||||
- **None** (default): software decode.
|
||||
- **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container.
|
||||
- **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container.
|
||||
- **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted:
|
||||
- `/hdhr/output_profile/<id>/lineup.json` - output profile only
|
||||
- `/hdhr/<channel_profile>/output_profile/<id>/lineup.json` - channel profile + output profile
|
||||
- Bare `/hdhr/lineup.json` - existing behavior, uses the new system default (see below)
|
||||
- The profile ID is resolved against active `OutputProfile` rows; if the ID is inactive or does not exist, a warning is logged and the stream is served without transcoding.
|
||||
- **HDHR default output profile setting.** A new "HDHR Default Output Profile" select in Settings → Stream Settings lets admins pick an output profile that is applied to all HDHR stream URLs that do not specify one in the path. When cleared, streams are served as-is (pass-through).
|
||||
- **fMP4 streaming support.** Dispatcharr can now serve live channels as fragmented MP4 in addition to MPEG-TS. A dedicated `FMP4RemuxManager` runs a single FFmpeg remux process per active channel, muxes incoming TS data into fMP4 fragments, and stores them in a Redis-backed ring buffer. Multiple clients requesting the same channel in fMP4 all read from the same shared buffer, so only one FFmpeg process runs regardless of viewer count. Clients that request MPEG-TS continue to be served as before. The output format is selected via `?output_format=mpegts|fmp4` on the stream URL, or by the per-user default configured by an admin on the user account, or by the server-wide default in Stream Settings.
|
||||
- **Output profiles.** Admins can define named transcode profiles under Settings → Output Profiles. Each profile specifies an executable (e.g. `ffmpeg`) and a parameter string that reads raw TS from `pipe:0` and writes the transcoded output to `pipe:1`. When a client requests a profile, one FFmpeg transcode process runs per active (channel, profile) pair and all requesting clients share the resulting output buffer. Common use case: a profile that converts AC3 audio to AAC for browser and mobile clients while the native stream (AC3 intact) continues to serve Plex/Emby/Jellyfin. Profiles are applied via `?output_profile=<id>` on the stream URL, or by per-user and server-wide defaults. Two locked built-in profiles are seeded on first run: **Media Server (AC3 Audio)** (copies video, re-encodes audio to AC3 384k) and **Web Player (AAC Audio)** (copies video, re-encodes audio to AAC 192k). (Closes #407)
|
||||
- **Container format and output profile shown in Stats client rows.** The expanded client row on the Stats page now displays the container format (`mpegts` or `fmp4`) and, when an output profile is active, the profile name.
|
||||
- **Browser-local web player output profile setting.** A new "Web Player Output Profile" select in Settings → UI Settings lets each browser choose which output profile (if any) is applied when previewing streams in the built-in floating player. The preference is saved to `localStorage` (`dispatcharr-player-prefs`) and picked up by every preview URL builder at click time, so a user whose account default transcodes to AC3 for their media server can still get a browser-compatible audio stream from the preview player without changing their account settings.
|
||||
- **DVR series rules: EPG channel is now optional.** Series recording rules no longer require a `tvg_id`. Rules with only a title or description filter search across all EPG channels in the 7-day horizon. The evaluator pre-loads one channel per EPG source (lowest channel number) in a single query and resolves the recording channel per-program, so cross-channel searches remain efficient. Saves and the preview endpoint now require at least one of `title`, or `description` instead of requiring `tvg_id`. The preview response includes a `warn: true` flag when more than 50 programs match, and the editor shows an orange alert when this flag is set. The upsert key for rules changed from `tvg_id` alone to `(tvg_id, title)` so multiple rules can target the same channel.
|
||||
- **DVR series rules: rich title and description matching.** Series recording rules now accept the same boolean / quoted-phrase / regex / whole-word semantics as the EPG Program Search API on both the title and description fields, plus an optional pinned channel. Existing rules continue to work unchanged (default `title_mode=exact`, no description filter, no pinned channel). (Closes #570)
|
||||
- New rule fields: `title_mode` (`exact` / `contains` / `search` / `regex`), `description`, `description_mode` (`contains` / `search` / `regex`), and `channel_id` (optional integer to pin recordings to a specific channel; defaults to the lowest-numbered channel for the EPG as before).
|
||||
- The boolean/quoted/regex/whole-word parser was extracted from `apps/epg/api_views.py` into a shared `apps/epg/query_utils.py` so the rule evaluator and the search endpoint use the same code path. No behavioral changes to the existing `/api/epg/programs/search/` endpoint.
|
||||
- The evaluator builds a single `ProgramData` queryset with `.distinct()` and reuses the existing `(tvg_id, start_time, end_time)` dedup key, so dropping in a description filter or a fuzzy title still preserves the dedup, episode collapsing, and offset-adjustment behavior. No N+1 introduced.
|
||||
- **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types.
|
||||
- **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches.
|
||||
- **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule.
|
||||
- **Shift+click and Ctrl+click row selection in tables.** Clicking anywhere on a non-interactive area of a row now participates in selection:
|
||||
- **Shift+click**: extends selection from the last-clicked row to the current row (range select), identical to shift+clicking the checkbox.
|
||||
- **Ctrl+click** (Cmd+click on Mac): toggles the clicked row in or out of the current selection without disturbing other selected rows.
|
||||
- Plain clicks on action buttons, checkboxes, inputs, links, and menus are unaffected. The expand chevron uses `stopPropagation` so expanding a row does not also trigger selection.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file.
|
||||
|
||||
- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations.
|
||||
- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected.
|
||||
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
|
||||
- **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect.
|
||||
- **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed.
|
||||
- **`get_client_ip()` in `dispatcharr/utils.py` cleaned up.** Removed dead `.split(',')[0]` call and misleading variable name left over from a copy-paste of the `X-Forwarded-For` pattern. `X-Real-IP` is always a single IP (set by nginx from `$remote_addr`), so no splitting is needed. Behavior is unchanged.
|
||||
- **`ts_proxy` module refactored and renamed to `live_proxy`.** The live-streaming proxy was reorganized into a structured package (`apps/proxy/live_proxy/`) with explicit submodules for each stage of the pipeline: `input/` (HTTP streamer, stream manager, TS ring buffer), `output/ts/` (MPEG-TS client generator), `output/fmp4/` (fMP4 remux manager, Redis buffer, client generator), and `output/profile/` (output profile transcode manager). A dedicated `redis_keys.py` module centralizes all Redis key name construction for the proxy. Existing behavior for MPEG-TS clients is unchanged.
|
||||
- **XC API `allowed_output_formats` now includes `mp4`.** The `user_info` block returned by `player_api.php` and `get.php` previously advertised only `["ts"]`. It now advertises `["ts", "mp4"]` for all users, enabling XC-compatible clients that support fMP4 to request `.mp4` stream URLs which are proxied as fMP4.
|
||||
- **Browser preview URLs always force `mpegts` output.** The four in-app preview buttons (Channels table, channel stream list, Streams table, Stats client row) now append `?output_format=mpegts` (and the web player profile, if set) to the preview URL so the built-in `mpegts.js` player always receives a compatible stream, regardless of the user's configured default output format.
|
||||
- **Series rules modal redesign.** Rules now display a one-line summary with the title-match mode, description filter (when present), and a "Pinned channel" badge when a `channel_id` is configured. The previous list rendering remains for legacy rules.
|
||||
- **EPG Program Search API** (`GET /api/epg/programs/search/`): a new endpoint for querying EPG program data with rich filtering and query support. - Thanks [@northernpowerhouse](https://github.com/northernpowerhouse)
|
||||
- **Text search** on title and description with AND/OR boolean operators (case-insensitive), quoted phrase matching (`"Law and Order"` treats _and_ as literal text), parenthetical grouping (`(Newcastle OR NEW) AND (Villa OR AST)`), whole-word mode (`title_whole_words=true`), and regex mode (`title_regex=true`).
|
||||
- **Time filters**: `airing_at` (programs live at a specific instant), `start_after`, `start_before`, `end_after`, `end_before`.
|
||||
- **Relational filters**: `channel`, `channel_id`, `stream`, `group`, `epg_source`.
|
||||
- **Field selection**: `fields=title,start_time,channels` returns only the requested keys; channel and stream data is skipped server-side (no wasted serialization) when not requested.
|
||||
- **Pagination**: default 50 results per page, configurable up to 500 via `page_size`.
|
||||
- **Access control**: results are scoped to channels the requesting user can access. `user_level` and the per-user adult-content filter are both enforced, matching the access model used by the M3U playlist, XC API, and stream proxy. Admin users receive unfiltered results.
|
||||
- Full OpenAPI/Swagger documentation available.
|
||||
- Requires `IsStandardUser` permission (user level ≥ 1).
|
||||
- **Per-user IP/CIDR network allowlists**. Admins can now assign IP address and CIDR range restrictions to individual user accounts via the API & XC tab on the user edit form. When a user has one or more allowed ranges configured, requests from IPs outside that list are rejected with `403 Forbidden` regardless of the global network access policy; if no ranges are configured, the user inherits global settings unchanged. The existing `network_access_allowed()` utility is extended with an optional `user` argument so the per-user check is enforced at all access-controlled entry points (M3U/EPG, Streams, XC API, UI) without duplicating IP-matching logic. Per-user restrictions are stored in `custom_properties['allowed_networks']`; no model changes or migrations are required. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **`reset_user_network` management command**: `manage.py reset_user_network <username>` clears the per-user `allowed_networks` restriction for the specified account, restoring it to global-policy inheritance. Useful for recovering a user locked out by a misconfigured allowlist. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Auto-sync overhaul**: comprehensive rebuild of the M3U auto-channel-sync flow. Introduces a per-field override system, hide-from-output flag, range-bounded auto-numbering with a re-pack helper, multi-stream channel safety, multi-provider shared-range merging, and an across-the-board move from per-row writes to bulk operations. (Closes #1196) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Per-field channel overrides for auto-synced channels.** A new `ChannelOverride` table (one-to-one with `Channel`) holds user-specified values for `name`, `channel_number`, `channel_group`, `logo`, `tvg_id`, `tvc_guide_stationid`, `epg_data`, and `stream_profile`. Sync writes only to `Channel.*` columns; the override row takes precedence at read time via a new `with_effective_values()` queryset helper that coalesces both sources at the SQL layer. Every output surface that consumes channel data reads through this helper so overrides surface consistently: HDHR (`/hdhr/lineup.json` and per-profile variants, `/hdhr/discover.json`), M3U (`/output/m3u`, per-profile variants), EPG (`/output/epg`, per-profile variants), XC API (`get_live_streams`, `xmltv.php`), the channels list/detail endpoints, and the TV Guide summary endpoint (`GET /api/channels/channels/summary/`). Channel API responses now include both the raw `name`/`channel_number`/etc. fields AND new `effective_*` annotations plus the `override` object, so frontend consumers can show both "what the user picked" and "what the provider sent" simultaneously.
|
||||
- **Per-field reset-to-provider icon.** FK pickers (channel group, logo, EPG, stream profile) and scalar inputs (name, channel_number, tvg_id, tvc_guide_stationid) display a small reset icon ("Provider: <value>" subtext + Undo2 button) when the field has an active override on an auto-synced channel. Clicking it sets the form value back to the provider value; the override for that field is cleared on save while other overrides remain intact.
|
||||
- **Auto-created channel source attribution.** The channel edit form shows an "Auto-created from: <provider name> / <stream name>" label at the top of auto-synced channels so the user can see which M3U account and which stream produced the row.
|
||||
- **Hide-from-output flag (`hidden_from_output`).** A user-toggleable boolean that excludes a channel from HDHR, M3U, EPG, and XC output without deleting it. Hidden channels are preserved across auto-sync refreshes and excluded from auto-cleanup. The channels table shows an EyeOff icon on hidden rows.
|
||||
- **Channels table visibility filter and inline indicators.** The table header has a new visibility selector ("Active Only" / "Hidden Only" / "Show All") plus a "Has Overrides" filter that narrows to channels with an active override row. Each row's name cell renders a yellow Pencil icon when overrides are active (tooltip lists the overridden field names) and an EyeOff icon when the channel is hidden.
|
||||
- **Bulk hide / unhide in the bulk edit modal.** Selecting any number of channels and toggling `hidden_from_output` in bulk edit applies the change in a single PATCH; auto-routes through the override-aware bulk endpoint so it works equivalently for auto-synced and manual channels.
|
||||
- **Auto-sync channel ranges per group.** `ChannelGroupM3UAccount` accepts `auto_sync_channel_start` (lower bound, inclusive) and `auto_sync_channel_end` (upper bound, inclusive; nullable for unbounded fill). Channels created by auto-sync are assigned numbers within the configured range; streams that do not fit are surfaced in the failure detail modal with a typed "RANGE_EXHAUSTED" reason.
|
||||
- **Per-group inline configuration in the M3U Live group filter.** Each group row now exposes a Sync toggle, a Numbering Mode selector (Provider / Next Available / Fixed), and Start/End channel-number inputs alongside the existing fields. The numbering modes control how each stream's channel number is chosen during sync: Provider tries the M3U-supplied number first then falls back to next-available; Next Available always picks the lowest free number from 1 upward; Fixed picks sequentially from the configured Start.
|
||||
- **Per-group "Configure" modal.** A gear-icon button on each group row opens a `GroupConfigureModal` containing the full set of advanced options. Fields surfaced in the modal: Channel Sort Order (provider order / name / tvg_id / updated_at, with reverse toggle), Compact Numbering toggle and "Re-pack now" button, Group Override (re-route this group's auto-created channels into a different `ChannelGroup` so multiple provider groups merge into one logical group), Name Regex Find/Replace pattern, Exclude Regex pattern, Force Dummy EPG, and a live regex preview pane.
|
||||
- **Live regex preview pane.** A new `GET /api/channels/streams/regex-preview/` endpoint scans the group's streams (capped at 5000) for the find / match / exclude patterns the user is editing and returns up to 10 sample matches per pattern plus accurate total counts. The frontend debounces the call (500ms) and runs it inside the configure modal so the user sees live evidence of what their patterns will match before saving.
|
||||
- **Live overlap warning across rows.** As the user edits Start/End values across multiple groups in the same provider, an `AbortController`-debounced scan calls a new `GET /api/channels/channels/numbers-in-range/` endpoint to detect (a) cross-row range overlaps (in-memory, all group pairs) and (b) channels already occupying the configured range that are not the expected occupants for that group. An informational warning surfaces inline; configurations are not blocked.
|
||||
- **Compact numbering with re-pack helper.** When `compact_numbering=True` on a group's account relation, sync packs visible auto-sync channels sequentially into the configured range. A "Re-pack now" button in the group config modal runs the pack manually via a new `POST /api/m3u/accounts/{id}/repack-group/` endpoint. Hidden channels do not occupy slots; un-hiding shifts visible numbers down to fill the gap. Override-pinned channel numbers are respected as fixed anchors. Single-channel hide / unhide via the post-save signal triggers an incremental `assign_compact_numbers_for_channels` pass without requiring a full sync.
|
||||
- **Multi-stream channel safety in sync.** A user-attached second stream on an auto-created channel no longer causes the channel to be deleted when one of those streams disappears from the provider. The deletion path filters out channels where at least one current stream remains alive, and per-stream iteration mutates the same `Channel` instance so `bulk_update` writes the merged final state.
|
||||
- **Orphan channel cleanup mode (3-state, account-scoped).** Stored under `M3UAccount.custom_properties.orphan_channel_cleanup` and surfaced as a SegmentedControl at the top of the M3U account's group-settings page. Three positions: `always` (default; removes every auto-channel whose source stream has disappeared), `preserve_customized` (removes orphans without a `ChannelOverride` row, preserves those with one), and `never` (preserves all orphans, intended for users with manual redundancy/failover stream setups). Hidden channels are universally preserved regardless of mode.
|
||||
- **Failure modal grouped by reason.** The auto-sync completion notification's failure detail modal groups entries by typed reason (`RANGE_EXHAUSTED`, `INTEGRITY_ERROR`, `OTHER`) with collapsible sections and a per-group cap. The in-memory cap was raised from 50 to 1000 so realistic multi-provider failure sets are not truncated. The notification's auto-close timeout extends from 4s to 12s when failures are present so users have time to see and click into the modal.
|
||||
- **Multi-provider shared-range merging.** Two M3U accounts can target the same group with overlapping channel-number ranges. Sync seeds `used_numbers` globally so the second provider's channels pick up where the first left off without colliding. The overlap warning in the group-settings UI surfaces this configuration as informational; the merge is intentional, not a hard error.
|
||||
- **Selection summary in the bulk edit modal.** The bulk edit form shows `Selection: X auto-synced, Y manual` at the top so users can see how a single set of changes will route between override rows and direct writes.
|
||||
- **Delete-Playlist preview.** A new `GET /api/m3u/accounts/{id}/auto-created-channels-count/` endpoint returns the count and up to 5 sample names of auto-created channels owned by the account. The Delete Playlist confirmation dialog uses this to show the user exactly how many channels will cascade away before they confirm.
|
||||
- **Custom `Channel.objects` manager.** A thin manager wraps the existing `with_effective_values()` helper so new code can call `Channel.objects.with_effective_values(...)` directly. The module-level helper remains the canonical implementation; existing call sites are unchanged.
|
||||
- **Frontend unit tests added for form components.** `GroupManager`, `LiveGroupFilter`, `LoginForm`, and `Logo` now have Vitest + Testing Library test suites covering rendering, user interactions, API integration, and edge cases (144 tests total). Business logic that was extracted into utility modules (`ChannelGroupUtils`, `LiveGroupFilterUtils`, `LogoUtils`, `M3uUtils`, `M3uFilterUtils`, `M3uGroupFilterUtils`, `M3uProfileUtils`, `AutoSyncAdvancedUtils`, `AutoSyncBasicUtils`) is also exercised through these tests. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Global Network Access settings use tag-style inputs**: the IP/CIDR range fields in the Network Access settings panel now use tag-style chip inputs instead of a plain text field, making it easier to add, review, and remove individual addresses or ranges. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Auto-sync overhaul** sync and channel behavior changes for the new override system. — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **`Channel.channel_number` is now nullable.** Auto-sync may produce channels without an assigned number (for example, a sync run where the configured range was exhausted before this stream could be slotted, or a hidden channel in compact mode whose slot was released for visible channels). Existing channels are unchanged. HDHR / M3U / EPG / XC output endpoints filter out channels with `null` channel_number, so a number-less row is invisible to clients but visible in the channels page where the user can assign one. The 0037 auto-sync overhaul migration includes a reverse-direction backfill on the `channel_number` AlterField step so a rollback that re-imposes NOT NULL still succeeds even if NULLs are present.
|
||||
- **M3U account delete always cascades auto-created channels.** The delete dialog is a single confirmation showing the count of affected channels (fetched from the new auto-created-channels-count endpoint). Auto-created channels are removed regardless of `hidden_from_output` or override state: override rows cascade via the one-to-one FK; hidden status does not preserve the channel because there is no provider left to populate it on the next sync. Manual channels are unaffected; their non-provider streams remain intact, and only streams owned by the deleted account are removed. The destroy response now returns `{"deleted_channels": N}` (HTTP 200) instead of an empty 204 so the confirmation toast can show the actual count.
|
||||
- **Bulk channel edit auto-routes auto-created channels through the override path.** When a bulk edit includes auto-synced channels, the changed fields are written to each channel's `ChannelOverride` row instead of the raw `Channel.*` columns, so the edit survives the next sync. Manual channels in the same selection write directly to `Channel.*`. A "Clear all overrides" affordance pre-clears overrides on the selection before applying new edits in the same submit (clear runs before the routing PATCH so a same-submit clear cannot wipe just-written overrides).
|
||||
- **Inline edits on the channels table route through the override row for auto-synced channels.** Editing a cell directly in the table (number, name, group, EPG, logo) now writes to `ChannelOverride.<field>` for auto-synced rows so the edit survives the next refresh; manual rows continue to write directly. The save mechanic and validation are unchanged from the user's perspective.
|
||||
- **Channel-number duplicates are explicitly allowed.** Sync's `used_numbers` seed still avoids assigning the same number to two newly-created auto channels in the same run, so accidental duplication during sync remains impossible. Manual or override-driven duplication is permitted; downstream client behavior on duplicates varies by client.
|
||||
- **gevent cooperative multitasking enabled in all uWSGI workers.** `gevent-early-monkey-patch = true` and `import = dispatcharr.gevent_patch` are now set in all four uWSGI configuration files (`uwsgi.ini`, `uwsgi.modular.ini`, `uwsgi.dev.ini`, `uwsgi.debug.ini`). The new `dispatcharr/gevent_patch.py` module ensures gevent's stdlib monkey-patching is applied before application code loads (replacing blocking socket, threading, and OS primitives with cooperative gevent equivalents) and installs psycogreen's wait-callback so psycopg2 database I/O yields to the gevent hub instead of blocking the OS thread. Without these settings, any blocking psycopg2, `requests`, or DNS call froze every greenlet on the affected worker for the duration of the call.
|
||||
- **WebSocket group sends rewritten to bypass asyncio in gevent workers.** `gevent.monkey.patch_all()` removes `select.epoll` from the stdlib `select` module, which breaks asyncio event loop creation in threadpool threads. The previous `send_websocket_update` and `_send_async` paths dispatched via `async_to_sync(channel_layer.group_send)()` from a threadpool thread, which failed with `AttributeError: module 'select' has no attribute 'epoll'`; the exception was caught at WARNING level, so every WebSocket push from REST views was silently discarded. A new `_gevent_ws_send()` in `core/utils.py` replicates the channels_redis 4.x `group_send` wire format directly using the synchronous Redis client - group membership lookup from the `asgi:group:{name}` sorted set, msgpack serialization with a 12-byte random prefix, and `ZADD` to per-channel sorted sets. Both `send_websocket_update()` and `_send_async()` detect gevent patching at call time and dispatch via `gevent.spawn(_gevent_ws_send)` instead. Celery workers, which are not gevent-patched, continue to use the `async_to_sync` path unchanged.
|
||||
- Dependency updates:
|
||||
- `Django` 6.0.4 → 6.0.5 (security patch; see Security section)
|
||||
|
||||
### Removed
|
||||
|
||||
- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead.
|
||||
- **`_cleanup_local_resources` skipped `ClientManager.stop()` on channel removal.** `ProxyServer._cleanup_local_resources` deleted each channel's `ClientManager` entry with `del`, which removed it from the dict but gave the object no signal to terminate. The per-channel heartbeat greenlet inside the manager continued running until it next checked its running flag and found the channel absent from Redis. The entry is now removed with `pop()` and `stop()` is called on the captured manager before it is discarded, terminating the heartbeat immediately on channel cleanup.
|
||||
- **XC profile `exp_date` not updating on account refresh.** `refresh_account_profiles` saved the freshly-fetched `custom_properties` with `update_fields=['custom_properties']`, which excluded `exp_date` from the SQL `UPDATE`. The model's `save()` method parses the new expiry from `custom_properties` and assigns it to `self.exp_date`, but that value was silently dropped because the column was not listed in `update_fields`. Added `'exp_date'` to the `update_fields` list so both columns are written together.
|
||||
- **~25-second transcode startup delay and worker freeze when starting ffmpeg under gevent+uWSGI.** Enabling gevent cooperative multitasking (see Changed above) exposed a deadlock: `fork()` hangs indefinitely in gevent's `_before_fork` pthread_atfork handler when called from any thread while gevent is running - including from real OS threads. `subprocess.Popen`, which all three ffmpeg spawn sites used, calls `fork()` internally, stalling the uWSGI worker for ~25 seconds or freezing it entirely and blocking all other clients on that worker.
|
||||
- `input/manager.py`: replaced `subprocess.Popen` with `os.posix_spawn` + a minimal `_SpawnedProcess` wrapper. `os.posix_spawn` is POSIX-specified to skip pthread_atfork handlers entirely.
|
||||
- `output/fmp4/manager.py`, `output/profile/manager.py`: replaced `subprocess.Popen` with a new shared `posix_spawn_proc()` helper in `live_proxy/utils.py`. The helper also sets `O_NONBLOCK` on the stdin pipe write-end: under gevent, `threading.Thread` is monkey-patched to greenlets, so a blocking write to a full 64 KB pipe would stall the entire hub. `_write_all()` in both output managers now treats a `None` return (EAGAIN on the non-blocking FD) as a cooperative wait via `select.select()` rather than a fatal error.
|
||||
- `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason.
|
||||
- `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages.
|
||||
- `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee.
|
||||
- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh.
|
||||
- **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218)
|
||||
- **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Login loading spinner not cleared on successful login.** `setIsLoading(false)` was inside the `catch` block only, so a successful login that immediately navigated away left the loading state as `true` if the component re-mounted. Moved to a `finally` block so it always resets. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Bulk channel edit silently failed on API rejection.** The bulk-edit submit handler in `ChannelBatch.jsx` only wrote `console.error` when the PATCH was rejected; the user saw the spinner stop but received no notification, leading them to assume the save succeeded. The catch block now surfaces a red toast with the server-provided detail (or a generic fallback), and the form stays open with the in-progress selection intact so the user can correct and retry. — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Recurring rule edit modal showed a blank Channel field.** The `RecurringRuleModal` read channel data from a Zustand store slice (`channels`) that nothing populates - the channels page and all other consumers switched to on-demand summary fetches in a prior release. Opening the edit modal from the DVR page always produced an empty Channel select. The modal now fetches channels via `API.getChannelsSummary()` when it opens, matching the lightweight approach used by the one-time recording form and other modals.
|
||||
- **DVR section count badges appeared at the far right of the screen.** The "Currently Recording", "Upcoming Recordings", and "Previously Recorded" section headers wrapped the title and badge in a `Group justify="space-between"`, which pushed the badge to the opposite edge of the full-width container. Changed to `Group gap="xs" align="center"` so each badge sits inline with its heading.
|
||||
- **Plugin event dispatch aborted silently on first disabled plugin.** `trigger_event` in `apps/connect/utils.py` iterated `pm.list_plugins()` and, for disabled plugins, logged a debug message using `plugin.key` / `plugin.name` (attribute access). Because `list_plugins()` returns dicts, this raised `AttributeError` on the first disabled plugin encountered. Fixed by changing the two accesses to `plugin['key']` / `plugin['name']`. (Fixes #1231) - Thanks [@R3XCHRIS](https://github.com/R3XCHRIS)
|
||||
- **Plugin periodic tasks silently missed the first beat tick after every Celery worker restart.** Plugin modules live outside `INSTALLED_APPS` so `autodiscover_tasks()` never imports them, and worker startup skips plugin discovery via `should_skip_initialization()`. Any plugin using module-level `@shared_task` had its tasks unregistered with the worker until a lazy event import warmed the module; beat fired on schedule but the worker rejected with `Received unregistered task` and advanced `last_run_at` anyway, hiding the miss. A `worker_ready` hook in `dispatcharr/celery.py` now eagerly calls `PluginManager.discover_plugins(sync_db=False)` on every worker boot so plugin tasks are registered before beat starts firing. (Fixes #1244) - Thanks [@R3XCHRIS](https://github.com/R3XCHRIS)
|
||||
- **Plugin monkey-patches and module-level hooks were never applied in uWSGI workers.** Both uWSGI configs use `lazy-apps=true`, meaning each worker boots independently and never inherits state from the master. `should_skip_initialization()` correctly skipped one-shot startup tasks in workers, but also blocked `discover_plugins`, so plugin modules were never imported in any of the 4 request-serving workers. Plugins relying on patching request handling (monkey-patches, signal registrations) were silently inactive until a Connect event lazily triggered discovery in that specific worker. Discovery is now run in every process that serves requests, gated only for Celery processes (which use `worker_ready`) and management commands that don't serve requests.
|
||||
- **PostgreSQL connection pool exhausted under load in gevent workers.** uWSGI's gevent pool runs many greenlets concurrently on a single OS thread. With `CONN_MAX_AGE = 60`, each greenlet that touched the database retained its own open connection for the full 60 seconds (gevent thread-locals are greenlet-locals), rapidly exhausting PostgreSQL's `max_connections` limit under moderate concurrency. `DATABASE_CONN_MAX_AGE` is now `0` so connections are closed after each request. `close_old_connections()` is also called at the top of the long-running stream manager and cleanup watchdog loops so stale handles accumulated across greenlet switches are released promptly.
|
||||
- **Stream proxy race: cleanup watchdog could stop a channel still in the connecting phase.** When the first viewer triggered channel initialization, the client was registered only after the connect-wait loop completed. The cleanup watchdog runs concurrently and stops channels with zero connected clients after a grace period; if the grace period elapsed during the connect-wait, the watchdog killed the channel and left the viewer stuck. The client is now registered before the connect-wait loop begins so the watchdog always sees at least one client.
|
||||
- **2-second "stream thread did not terminate within timeout" warning on every channel stop.** `_close_socket` in `input/manager.py` was closing the relay pipe read-end (`self.socket`) before killing the ffmpeg process. The stream OS thread blocks in `select()` on that fd; on Linux, closing an fd from another thread while a `select()` is in progress on it does not reliably interrupt the call (POSIX allows this to be undefined). The thread stayed blocked for the full chunk-timeout (5 s), and `stream_thread.join(timeout=2.0)` always expired first. Fixed by killing ffmpeg first: when ffmpeg dies its copy of the relay write-end closes, delivering EOF to `select()` immediately. `self.socket` is closed afterward as cleanup only.
|
||||
- **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger.
|
||||
- **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block.
|
||||
- **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes.
|
||||
- **Shift+click range selection in the channels table broken after row memoization.** After `MemoizedTableRow` was introduced, `handleShiftSelect` captured `lastClickedId` from its render-time closure. Because the memo comparator intentionally excludes callback function references, unselected rows retained the stale closure where `lastClickedId === null`, so every shift+click from a previously unchecked row fell through to a plain toggle instead of selecting the range. Added `lastClickedIdRef` and `allRowIdsRef` alongside the existing `selectedTableIdsRef`; `handleShiftSelect` now reads from those refs so every row uses the current anchor ID and full ID list regardless of which render produced the closure.
|
||||
- **Selected rows in the channels table did not show the teal highlight.** `MemoizedTableRow` applied `backgroundColor: '#163632'` based on `row.getIsSelected()` from TanStack Table's API. Because `state.rowSelection` was never wired into `useReactTable`, `row.getIsSelected()` always returned `false` and selected rows remained unstyled. Changed to use the `isSelected` prop, which is correctly derived from `selectedTableIdsSet` and already tracked by the memo comparator.
|
||||
- **`blur` event listener in `useTable` leaked on component unmount.** The `useEffect` cleanup function called `window.removeEventListener('blur', ...)` with a newly created anonymous function literal that never matched the handler registered at setup time, so the listener was never removed. Extracted to a named `handleBlur` constant so setup and cleanup reference the same function.
|
||||
|
||||
### Performance
|
||||
|
||||
- **EPG HTTP response cache replaced with `django-redis`.** The default Django cache backend was `LocMemCache`, an in-process memory store. With multiple uWSGI workers, each worker independently generated and cached the full EPG XML in its own heap; with 4 workers the peak memory cost was up to 4 times the size of the EPG document. The cache backend is now `django_redis.cache.RedisCache`, backed by the same Redis instance used by `channels_redis`, so a single cached EPG copy is shared across all workers.
|
||||
- **`AutoSyncAdvanced` and `LogoForm` are now lazy-loaded in the M3U group filter.** Both components are large and only needed when the user opens the gear modal or logo upload modal. Wrapping them in `React.lazy` + `Suspense` removes them from the initial bundle and defers their parse/execute cost until first use. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Auto-sync at scale**: the new override-aware sync flow is more capable than the prior path but the implementation choices below keep it viable on libraries with thousands of channels. — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Bulk writes throughout `sync_auto_channels`.** Per-row `Channel.objects.create()` and `.save()` calls were replaced with `bulk_create()` and `bulk_update()` paths that batch the entire group's create + update sets into single round-trips. The renumber pass collects all dirty channels into one list and flushes with a single `bulk_update` at the end of the loop. `ChannelStream.order` writes were similarly consolidated into a single `bulk_update`.
|
||||
- **Single-pass collision detection via a global `used_numbers` set.** Choosing a free channel number was previously an `O(N)` DB query per stream. The new path seeds a `set()` of all reserved numbers (existing channels + override pins) once per run; `_next_available_number()` is `O(cluster size)` against that set. The seed deliberately excludes only this account's visible auto-created channels (the rows about to be reassigned), so cross-account and hidden-channel reservations are honored without extra queries.
|
||||
- **SQL-level effective-value coalescing via `with_effective_values()`.** All channel-data consumers (HDHR, M3U, EPG, XC, channel list / detail, TV Guide summary) read effective values directly from a single annotated queryset that left-joins the override row at the database layer. Prevents N+1 `ChannelOverride` lookups across the channel scan and lets the same query serve the join.
|
||||
- **EPG dispatch deduplication.** `bulk_create` and `bulk_update` bypass `post_save`, so the post-loop step explicitly dispatches `parse_programs_for_tvg_id.delay` once per unique `epg_data_id` actually changed in the run, not per channel. Avoids fan-out where one EPG source change otherwise queued thousands of redundant parse tasks.
|
||||
- **Logo and EPGData lookup caching during sync.** `sync_auto_channels` now caches Logo and EPGData lookups in a per-run dict so repeated provider URLs / IDs across many streams do not produce duplicate `Logo.objects.get_or_create` or `EPGData.objects.filter` calls.
|
||||
- **Override-aware bulk PATCH endpoint.** `update_channels_with_override_routing` partitions the bulk-edit selection into auto-created (override write) vs manual (direct write) once, then issues two bulk PATCHes instead of N single-row updates.
|
||||
- **`xc_get_live_streams` response-build overhead reduced.** Previously the function iterated the full channel queryset three times (two passes for collision-free integer number mapping, one pass to build the response list) and called `reverse()` and `build_absolute_uri_with_port()` once per channel to construct logo URLs, amounting to N URL-pattern lookups and N header-parse calls per request. The number-mapping passes are now combined into one full queryset pass that immediately classifies channels with integer numbers, deferring only the fractional-number subset to a second, smaller loop. Logo URL construction is precomputed once per request using a single `reverse()` call; per-channel URLs are assembled with string interpolation. `_get_default_group_id()` is also called at most once per null-group channel instead of twice. The `get_live_streams` API endpoint now returns a `StreamingHttpResponse` backed by a generator that serializes one channel entry at a time instead of building the full JSON array in memory before writing; time-to-first-byte is lower on large channel libraries and peak worker memory is O(1) per channel rather than O(N). (Fixes #1220)
|
||||
- **Stream filter-options fast path when no filters are active.** `GET /api/channels/streams/filter-options/` previously ran `DISTINCT` queries across the full streams table while also mutating the underlying Django request object to strip inapplicable filter params. With no active filters the fast path now queries the streams table directly with two simple `DISTINCT` aggregations, skipping the request mutation and filterset instantiation overhead.
|
||||
- **Channel list queryset drops unconditional `DISTINCT`.** `ChannelViewSet.get_queryset()` previously appended `.distinct()` unconditionally. `DISTINCT` is only needed when the query joins a one-to-many table - specifically when `channel_profile_id` or `only_stale` filters are active. The queryset now returns plain results in the common case, eliminating sort-and-deduplicate overhead on most channel list fetches.
|
||||
- **`JsonResponse` for ID list and channel summary endpoints.** The `stream_ids`, `channel_ids`, and channel `summary` endpoints now use `django.http.JsonResponse` instead of DRF's `Response`, bypassing the DRF renderer pipeline (content-type negotiation, serializer dispatch) for responses that are already plain Python structures. Removes overhead on every channel-table load and stream-table load.
|
||||
- **Logo queryset annotates `channel_count` to eliminate N+1 in `LogoSerializer`.** `LogoViewSet.get_queryset()` now annotates each row with `Count('channels')`. `LogoSerializer.get_channel_count()` and `get_is_used()` read the annotation directly instead of issuing a separate `COUNT(*)` per logo. The `used=true` and `used=false` list filters use the annotation for their conditions, removing the `DISTINCT` that was previously required.
|
||||
- **`ChannelProfileSerializer` reads prefetched memberships.** `ChannelProfileViewSet.get_queryset()` now prefetches enabled `ChannelProfileMembership` rows into `enabled_memberships`. `ChannelProfileSerializer.get_channels()` uses the prefetched set when available, eliminating one query per profile in any response that lists multiple profiles.
|
||||
- **Channel table selection re-render reduction.**
|
||||
- Removed `isShiftKeyDown` React state from `useTable`. Setting it on every `keydown`/`keyup` event triggered a re-render of any table consumer (`ChannelsTable`, `CustomTableBody`, ~50 memoized row comparisons) each time shift was pressed or released. The visual shift-key effect is handled entirely by `document.body.classList` manipulation, so the React state served no purpose.
|
||||
- Removed the `selectedChannelIds` reactive Zustand store subscription from `ChannelsTable`. Each checkbox click wrote to both local `selectedTableIds` state and the store via `onRowSelectionChange`, causing two sequential re-renders of `ChannelsTable` per click. The two consumers of this subscription (`deleteChannel` and `ChannelBatchForm`) now read from `table.selectedTableIds` directly.
|
||||
- Removed the dead `rowSelection` useMemo that built a TanStack row-selection map from `selectedTableIds`. The map was placed in `tableInstance` but `state.rowSelection` was never passed to `useReactTable`, so TanStack never consumed it. Eliminated a full page-row iteration on every selection change.
|
||||
|
||||
## [0.24.0] - 2026-05-03
|
||||
|
||||
### Security
|
||||
|
||||
- **HDHomeRun discovery endpoints now respect the `M3U_EPG` network access policy**. `DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, and `HDHRDeviceXMLAPIView` were marked `AllowAny` so HDHR clients (Plex, Emby, Jellyfin, Channels DVR, etc.) can discover the tuner without authenticating, but they were not gated by any network allowlist. The lineup enumerates every channel name and per-channel UUID stream URL, so any client that could reach the server could full-enumerate the lineup. All four views now call `network_access_allowed(request, "M3U_EPG")` and return `403 Forbidden` for clients outside the allowlist, matching the gating already applied to the M3U and EPG endpoints (and matching what the Network Access settings UI already advertised: "Limit access to M3U, EPG, and HDHR URLs"). Operators with a restrictive `M3U_EPG` policy will see HDHR discovery start being blocked for off-LAN clients on upgrade; loosen the policy if remote HDHR access is required.
|
||||
- **Removed `dangerouslySetInnerHTML` from the M3U Profile regex preview**. The "Matched Text" preview in the M3U Profile editor built an HTML string by interpolating the user's sample input into a `<mark>` wrapper and rendering it via `dangerouslySetInnerHTML`. A crafted sample input (admin-only, so self-XSS only) could inject arbitrary HTML into the preview pane. The preview now returns an array of plain strings and `<mark>` React elements, so user input is always treated as text by React.
|
||||
- **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token.
|
||||
- Updated `lxml` 6.0.3 → 6.1.0, resolving the following CVE:
|
||||
- **CVE-2026-41066**: External entity injection (XXE) in `iterparse()` and `ETCompatXMLParser`.
|
||||
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high):
|
||||
- Updated `@xmldom/xmldom` 0.8.12 → 0.8.13, resolving **high** uncontrolled recursion in XML serialization causing DoS ([GHSA-2v35-w6hq-6mfw](https://github.com/advisories/GHSA-2v35-w6hq-6mfw)), **high** XML injection via unvalidated `DocumentType` serialization ([GHSA-f6ww-3ggp-fr8h](https://github.com/advisories/GHSA-f6ww-3ggp-fr8h)), **high** XML node injection via unvalidated processing instruction serialization ([GHSA-x6wf-f3px-wcqx](https://github.com/advisories/GHSA-x6wf-f3px-wcqx)), and **high** XML node injection via unvalidated comment serialization ([GHSA-j759-j44w-7fr8](https://github.com/advisories/GHSA-j759-j44w-7fr8))
|
||||
- Updated `postcss` 8.5.6 → 8.5.13, resolving **moderate** XSS via unescaped `</style>` in CSS stringify output ([GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **EPG program times shifted by the host system's UTC offset when `/etc/localtime` is bind-mounted into the container**. Mounting `/etc/localtime` from a non-UTC host causes PostgreSQL to silently resolve the `'UTC'` timezone name to the host's local timezone (e.g. CDT, CET) rather than actual UTC - even though `SHOW timezone` returns `UTC` and the zoneinfo file exists. This made PostgreSQL format all stored `timestamptz` values with the host's UTC offset, and psycopg2 returned datetimes shifted by that offset, causing every EPG program time to be read back N hours wrong and written to the XML output incorrectly. The fix registers a Django `connection_created` signal that issues `SET TIME ZONE 'UTC0'` on every new database connection. `UTC0` is a POSIX timezone string that bypasses the broken zoneinfo name-lookup path entirely; it resolves unconditionally to UTC+00 regardless of the host timezone or what files are mounted. (Fixes #651)
|
||||
- **Xtream Codes `player_api.php` missing `active_cons` and reporting wrong `max_connections`**. The `user_info` block returned by the XC API did not include the `active_cons` field, which Enigma2 clients (XStreamity, XKlass) read unconditionally and crash with `KeyError: 'active_cons'` when it is absent. `max_connections` was also hardcoded to the system-wide tuner count for every user, ignoring per-user `stream_limit` configuration. `xc_get_info` now reports `max_connections` as the user's `stream_limit` when set, falling back to the system tuner count for unlimited users; `active_cons` is the user's own active connection count when they have a per-user limit, or the system-wide active connection count when they do not (so unlimited clients can still see how much of the global tuner pool is in use). The existing `get_user_active_connections` helper was generalized to accept `user_id=None` for the system-wide query rather than duplicating its Redis scan logic. (Fixes #990)
|
||||
- **Plugin discovery re-running on every connect event**. The `PluginManager` cache hit check used `if self._registry and ...`, which always evaluated false when zero plugins were installed because an empty dict is falsy. Every Connect event (`recording_start`, `client_connect`, `channel_start`, etc.) was therefore triggering a full filesystem walk of `/data/plugins` and emitting `Discovering plugins (no DB sync) in /data/plugins` / `Discovered 0 plugin(s)` log lines. Tracked separately via a new `_discovery_completed` flag so the cache short-circuits subsequent calls regardless of registry contents, dropping discovery to once per worker process lifetime.
|
||||
- **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched.
|
||||
- **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show.
|
||||
- **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name.
|
||||
- **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545)
|
||||
- **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134)
|
||||
- **Channel start/client connect notifications suppressed on first stats poll after page load**: after logging in or navigating to the Stats page, the notification logic was not firing for any connections present in the first stats response, even for streams that had started after the page loaded. Replaced the flag-based approach with a `pageLoadTime` module-level constant compared against each client's `connected_at` Unix timestamp from Redis; connections that pre-date the page load are filtered out, while genuinely new ones fire immediately. `get_basic_channel_info` now also includes `connected_at` in each client entry so this check works for the Stats page's API poll path as well as the WebSocket path.
|
||||
|
||||
### Added
|
||||
|
||||
- **In-progress recording playback from the DVR page**. The Watch button on a recording card is now enabled while the recording is still in progress, and the in-app floating player can play the live HLS playlist with full timeshift / scrub-back to the start of the recording.
|
||||
- The frontend now bundles `hls.js` and routes any `.m3u8` URL through it (Chrome, Edge, Firefox, and other Chromium-based browsers). Native HLS via `<video src=>` is reserved for Safari, where it Just Works for the same playlist.
|
||||
- hls.js requests are authenticated via `xhrSetup`, attaching the same `Authorization: Bearer <accessToken>` header the live mpegts.js player already uses, so the new per-user authorization check on the recording endpoints (see Security) is satisfied for every playlist refresh and segment fetch.
|
||||
- **Watch DVR recordings live while they are still recording**: `run_recording` has been refactored from a single TS file capture to an FFmpeg HLS segmentation pipeline. While recording is in progress, the `file_url` exposed on the recording points to a new `/api/channels/recordings/{id}/hls/index.m3u8` endpoint instead of the eventual MKV path, so any HLS-capable client (the built-in web player, VLC, infuse, channels DVR clients, etc.) can join the stream at any time and watch from the live edge. When the recording ends, the segments are concatenated into the final MKV, `file_url` is updated to point to the existing `/file/` endpoint, and the HLS working directory is cleaned up. Hitting `/file/` while a recording is still in progress now redirects to the HLS playlist rather than 404, and hitting `/hls/index.m3u8` after the recording has finalized redirects to `/file/`, so URLs cached by clients in either form continue to work across the transition.
|
||||
- New HLS playback endpoint (`RecordingViewSet.hls`) serves `.m3u8` and `.ts` files out of the recording's working directory with path traversal protection (`os.path.realpath` containment check). Playlist files are rewritten on the fly so each segment line becomes an absolute URL routed back through this endpoint, preserving authentication and path isolation. Both this and the existing `/file/` endpoint are gated by the `STREAMS` network policy and a per-user authorization check (see Security).
|
||||
- Explicit `path('recordings/<int:pk>/hls/<path:seg_path>', ...)` route registered in `apps/channels/api_urls.py` _before_ `router.urls`. DRF's `DefaultRouter` appends a mandatory trailing slash to every URL it generates, but HLS players request segments by their natural filenames (`seg_00001.ts`) without a trailing slash, so the explicit route is required for the player to ever reach the view.
|
||||
- `DISPATCHARR_WEB_HOST` environment variable for modular deployments. The Celery container needs to reach the uWSGI / web container to fetch HLS segments while recording (FFmpeg pulls from the public stream URL, the same way any other client does). `get_dvr_stream_base_url()` resolves the base URL deterministically: AIO / dev / debug containers reach uWSGI on `127.0.0.1:$DISPATCHARR_PORT` since Celery and uWSGI share the container, while modular deployments use `http://$DISPATCHARR_WEB_HOST:$DISPATCHARR_PORT` and default `DISPATCHARR_WEB_HOST` to `web` (the compose service name). Documented as a commented-out override in `docker/docker-compose.yml`.
|
||||
- HLS viewer heartbeat. Every `.ts` request refreshes a Redis key `dvr:hls_viewer:{id}` with a 20 second TTL. After concat succeeds, the recording task waits for that key to expire naturally before deleting the HLS working directory, so an actively-watching client is never cut off mid-segment when the recording ends. Cleanup happens within 20 seconds of the last client stopping, with a 4 hour safety cap as a guard against a stuck Redis state.
|
||||
- **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page.
|
||||
- **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo)
|
||||
- **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Targeted stream-stats refresh in the channel table**: expanding a channel row now refreshes `stream_stats` for that channel's streams via a new lightweight delta endpoint (`GET /api/channels/channels/{channel_id}/streams/stats/`) instead of relying on a full channel-list re-fetch. The endpoint accepts a `since` (ISO 8601) cursor and an optional `ids` filter and returns only streams whose `stream_stats_updated_at` is strictly newer than the cursor, so the response is empty when nothing has changed. The frontend computes the cursor on demand from the streams already in the store, fires the request once on row expand, and fires it again scoped to a single stream ID when the in-app preview player closes. A new `patchChannelStreamStats` Zustand mutator merges the response into the store while preserving object identity for unchanged channels and streams, so memoized rows do not re-render. This restores the live-stats refresh behaviour that the channel-table performance work removed (`requeryChannels()`) without re-introducing the page-wide re-fetch.
|
||||
- **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL.
|
||||
|
||||
### Performance
|
||||
|
||||
- **TS proxy buffer writes pipelined**: `StreamBuffer.add_chunk` previously issued five separate Redis commands per buffered chunk (`incr`, `setex`, `zadd`, `zremrangebyscore`, `expire`), each a full round trip. The `incr` is still issued first because its return value is needed to build the chunk key, but the remaining four commands are now queued on a non-transactional pipeline and flushed in a single `execute()` call. Drops per-chunk Redis round trips from 5 to 2 on busy channels.
|
||||
- **TS proxy per-client stats writes throttled**: `ClientStreamer._process_chunks` was issuing a Redis `hset` of client stats (chunks sent, bytes sent, transfer rates, last_active) on every chunk delivered to every client. The `hset` is now gated to once per second per client via a new `stats_write_interval` throttle, matching the actual polling cadence of the frontend stats panel. The TTL refresh logic and in-memory rate calculations are unchanged. Dramatically reduces Redis traffic on channels with many concurrent viewers.
|
||||
- **`send_m3u_update` skips redundant `M3UAccount` lookup**: the helper used to re-fetch the `M3UAccount` row on every progress tick just to populate `status` and `last_message`. It now skips the query entirely when the caller has already supplied both fields in `kwargs`, and uses `.only("status", "last_message")` when the lookup is needed. Removes thousands of pointless `SELECT` queries from M3U download and processing progress streams.
|
||||
- **M3U auto-channel orphan cleanup**: replaced a redundant `orphaned_channels.count()` followed by `.delete()` with a single `qs.delete()` call that uses the count returned by Django's delete. Saves one COUNT query per auto-sync run.
|
||||
- **Channel table performance**:
|
||||
- Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports.
|
||||
- Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization.
|
||||
- Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator.
|
||||
- Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator.
|
||||
- Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. Previously dragging a stream to a new position triggered a PATCH, then `requeryStreams()` (re-fetching all streams), then `requeryChannels()` (re-fetching the entire paginated channel list with all embedded stream objects). The reorder now uses a dedicated `API.reorderChannelStreams()` path that issues only the PATCH, then updates the store in-place by reordering the existing stream objects without any network round-trips. On failure, `requeryChannels()` is called to restore correct state.
|
||||
- Fixed N+1 `UPDATE` queries in the stream-order write path. `ChannelSerializer.update()` and the bulk-edit view were calling `ChannelStream.save(update_fields=["order"])` once per stream whose position changed. Both now collect all modified `ChannelStream` objects and issue a single `ChannelStream.objects.bulk_update(…, ["order"])` call. Also removed an accidental `print(normalized_ids)` debug statement left in the serializer.
|
||||
- Applied the same lightweight store-update approach from stream reorder to stream removal. `removeStream` previously called `API.updateChannel` (which internally triggered `requeryStreams`), then also explicitly called `requeryChannels()` and `requeryStreams()`. Removal now calls `API.reorderChannelStreams` with the remaining stream list (optimistic local `setData` first), matching the one-request pattern used by drag reorder.
|
||||
- `removeStream` in `ChannelTableStreams` was capturing `data` and `channel` in its closure, causing the `columns` `useMemo` to recreate the entire column array (and new TanStack table instance) on every reorder or remove. Both values are now read through refs (`channelRef`, `dataRef`) so `removeStream` has no dependencies and is stable for the lifetime of the component. Removed `removeStream` and `playlists` from the `columns` dep array.
|
||||
- `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving.
|
||||
- Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup.
|
||||
- Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading.
|
||||
- Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips.
|
||||
- Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel.
|
||||
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
|
||||
- Eliminated per-tick DB queries from the channel stats system. Previously `get_basic_channel_info()` in `channel_status.py` issued a `Stream.objects.filter()` and an `M3UAccountProfile.objects.filter()` on every stats tick for each active channel to resolve display names. Channel name and stream name are now written into the Redis metadata hash at channel-init time (via `initialize_channel`) and read back directly during stats collection, zero DB queries per tick. `m3u_profile_name` is now resolved on the frontend from the already-loaded playlists store rather than being pushed from the backend.
|
||||
- Eliminated a redundant `Channel` DB lookup inside `ChannelService.initialize_channel()`. `views.py` already fetches the `Channel` object via `get_stream_object()` before calling `initialize_channel()`; `channel.name` is now passed as an optional `channel_name` parameter, so the service uses the caller-supplied value and only falls back to a DB query when the name is not provided (e.g. stream-preview paths). A matching `stream_name` parameter was added for the same reason; the `stream_name` DB query is skipped entirely on normal channel start since a channel name is always available.
|
||||
- Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known.
|
||||
- **Dedicated thread-pool Celery worker for DVR recordings**. `run_recording` is long-running and almost entirely I/O-bound: it loops in short ticks polling FFmpeg, the DB, and Redis for the full duration of the recording. Running it on the default prefork worker pool (`--autoscale=6,1`) meant at most 6 concurrent recordings, and only if no other background work was running. M3U refreshes, EPG parsing, channel matching, comskip, etc. all competed for the same 6 slots, so if every worker was busy when a recording's start time arrived, the task would queue in Redis and FFmpeg would not start until a worker freed up, causing the user to miss the beginning of the show.
|
||||
- A second Celery worker is now started alongside the default one, configured with `--pool=threads --concurrency=20` and bound to a dedicated `dvr` queue. Threads fit this workload because every blocking call in `run_recording` releases the GIL (sleep, subprocess wait, file / DB / Redis I/O) and FFmpeg itself runs in a separate OS process. A `task_routes` entry in `dispatcharr/celery.py` routes `apps.channels.tasks.run_recording` to the `dvr` queue regardless of how it is dispatched (Celery Beat `PeriodicTask`, `.delay()`, etc.). The default prefork worker now subscribes only to the `celery` queue (`-Q celery -n default@%h --autoscale=6,1`), so recordings can no longer starve background tasks and background tasks can no longer delay recordings.
|
||||
- Net result: up to 20 concurrent recordings, zero startup delay regardless of EPG / M3U refresh activity, and a memory cost of roughly 80 to 120 MB for the second always-on worker process. Each additional concurrent recording costs nearly nothing on top because all 20 threads share that single process. Applied to AIO (`docker/uwsgi.ini`), dev (`docker/uwsgi.dev.ini`), debug (`docker/uwsgi.debug.ini`), and the modular celery container (`docker/entrypoint.celery.sh`).
|
||||
- **Added database index on `Stream.name`**. The stream list default sort is `ORDER BY name`, but the column had no index, causing full table scans that spilled to disk (observed at ~38 MB temp file / ~800 ms) on large stream libraries. `db_index=True` is now set on the field, letting PostgreSQL satisfy the sort via an index scan with no disk spill. (Fixes #1209)
|
||||
- **Streams table now only refetches what changed**. Loading the Channels page fired `/streams/`, `/streams/ids/`, and `/streams/filter-options/` together via `Promise.all`, and the trio refired on every pagination, sort, or filter change. The IDs list and filter options don't depend on page or sort order, so they were re-pulled needlessly (~440 KB per pagination round trip on a 70k-stream library). `fetchData` was split into a `fetchPageData` for the visible rows (depends on page + sort + filters) plus dedicated effects for the IDs and filter-options endpoints (filters only). Initial-load timing is unchanged (still parallelized), but every subsequent paginate or sort toggle now hits only `/streams/`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Plugin discovery startup gated to the main process**. `apps/plugins/apps.py` now consults `should_skip_initialization()` before its eager in-memory `discover_plugins()` pass in `AppConfig.ready()`. The pass previously ran in every uwsgi worker fork, every Celery worker, and every `manage.py` invocation. Plugin discovery now happens lazily on first Connect event in worker processes (cached thereafter); the existing `post_migrate` handler still keeps the database side in sync.
|
||||
- DVR recording pipeline rewritten around FFmpeg + HLS. The previous implementation wrote a single growing `.ts` file via the proxy and remuxed it to `.mkv` at the end; the new implementation runs FFmpeg with `-f hls`, regenerates monotonic PTS to handle erratic IPTV source timestamps, shifts output timestamps so they start at 0 (fixing negative-PTS streams that prevented HLS segment boundary detection), and concatenates the resulting segments into the final MKV at the end of the recording. Server-restart resume now continues segment numbering from the previous session rather than overwriting earlier segments.
|
||||
- Stall detection added to the recording loop. Once the first segment confirms the stream is flowing, the loop watches both segment count and segment file mtime. If neither advances for the configured stall window (e.g. when an upstream proxy ghost-kills the client), FFmpeg is signaled and the recording ends cleanly rather than hanging until `end_time`. Recording duration is honored via SIGINT so FFmpeg writes `#EXT-X-ENDLIST` cleanly into the final playlist.
|
||||
- `end_time` extension is now picked up by the running recording without restarting FFmpeg. The DB poll loop simply updates the in-memory deadline.
|
||||
- Recording cancellation cleanup updated for the new layout. `RecordingViewSet.destroy()` now removes the HLS working directory via a `_safe_rmtree` helper (with the same `allowed_roots` containment check used for the existing `_safe_remove`) instead of trying to delete the obsolete `_temp_file_path`.
|
||||
- HLS working directory lifecycle in `custom_properties` is now two-phase. After concat succeeds, only `file_url` and `output_file_url` are updated so new client requests are redirected to the final MKV via `/file/`; `_hls_dir` is intentionally preserved through the viewer-wait grace period so in-flight `.ts` requests keep resolving. `_hls_dir` is cleared from `custom_properties` only after `shutil.rmtree` actually removes the directory.
|
||||
- **Column resizing in `CustomTable`**: column widths are now propagated to body cells via CSS custom properties (`--header-{id}-size`) injected on the table wrapper, rather than reading `column.getSize()` directly in each cell's React style. This decouples body-cell widths from React renders so that memoized rows (which skip re-renders for performance) still reflect resize changes instantly via CSS cascade.
|
||||
- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state.
|
||||
- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows.
|
||||
- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths.
|
||||
- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed.
|
||||
- **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications.
|
||||
- **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response.
|
||||
- **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`).
|
||||
- Dependency updates:
|
||||
- `psycopg2-binary` 2.9.11 → 2.9.12
|
||||
- `lxml` 6.0.3 → 6.1.0 (security patch; see Security section)
|
||||
- `sentence-transformers` 5.4.0 → 5.4.1
|
||||
- `@xmldom/xmldom` 0.8.12 → 0.8.13 (security patch; see Security section)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead M3U helper methods**: deleted `M3UAccount.deactivate_streams`, `M3UAccount.reactivate_streams`, and `M3UFilter.filter_streams`. None had any callers anywhere in the codebase. New code that needs to flip `is_active` on every stream of an account should use `self.streams.update(is_active=...)` rather than reintroducing a per-row `.save()` loop.
|
||||
- **HDHomeRun SSDP advertiser**. The `apps/hdhr/ssdp.py` module and the `start_ssdp()` call in `apps.hdhr.apps.HdhrConfig.ready()` have been removed. The implementation advertised a `urn:schemas-upnp-org:device:MediaServer:1` UPnP MediaServer device on `udp/1900`, but Dispatcharr does not implement the UPnP MediaServer Content Directory Service that such an advertisement promises, and the `LOCATION`-targeted `/hdhr/device.xml` returns HDHomeRun-flavored XML rather than the UPnP descriptor a generic UPnP browser expects. None of the major HDHomeRun clients (Plex, Emby, Jellyfin, Channels DVR, Kodi) use SSDP for tuner discovery, they use SiliconDust's native UDP broadcast on `udp/65001`, which Dispatcharr does not currently implement. The advertiser ran a listener thread, a broadcaster thread, a bound multicast socket, and emitted a `NOTIFY` broadcast every 30 seconds for no consumer. Tuners can still be added in any HDHomeRun-aware client by entering the Dispatcharr URL manually (e.g. `http://<host>:9191/`).
|
||||
|
||||
## [0.23.0] - 2026-04-17
|
||||
|
||||
### Security
|
||||
|
||||
- Set `DEFAULT_PERMISSION_CLASSES` to `IsAdmin` in the DRF configuration. All viewsets and function-based views that require non-admin or unauthenticated access were explicitly annotated: proxy streaming endpoints (`stream_ts`, `stream_xc`, `stream_vod`, `head_vod`, `stream_xc_movie`, `stream_xc_episode`) use `@permission_classes([AllowAny])` (access is controlled by the per-stream-type network allow-list inside the view body); the `UserAgentViewSet`, `StreamProfileViewSet`, `CoreSettingsViewSet`, and `ProxySettingsViewSet` gained `get_permissions()` methods mapping read actions to `IsStandardUser` and write actions to `IsAdmin`; and `AuthViewSet.logout` was updated to return `[Authenticated()]`.
|
||||
- Fixed missing `network_access_allowed` checks in the VOD proxy. `stream_vod`, `head_vod`, `stream_xc_movie`, and `stream_xc_episode` were not checking the `STREAMS` network policy, unlike the equivalent TS proxy endpoints.
|
||||
- Explicitly marked the HDHomeRun discovery endpoints (`DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, `HDHRDeviceXMLAPIView`) and the version endpoint with `permission_classes = [AllowAny]` to document their intentionally public access now that the global default is `IsAdmin`.
|
||||
- Fixed path traversal vulnerability in file uploads. The M3U account upload (`apps/m3u/api_views.py`), logo upload (`apps/channels/api_views.py`), and backup upload (`apps/backups/api_views.py`) all used the uploaded filename directly without sanitization. `os.path.join()` discards all preceding components when it encounters an absolute path segment, and `pathlib`'s `/` operator behaves identically; a relative `../` sequence also escapes via OS path resolution at `open()` time. All three upload paths now strip directory components via `Path(name).name` and validate the resolved path remains within the intended upload directory. Exploiting any of these required admin credentials.
|
||||
- Prevented users from setting `xc_password` (and other admin-managed keys) on their own account via the `PATCH /api/accounts/users/me/` endpoint.
|
||||
- Hardened the HLS proxy `change_stream` endpoint by converting it from a plain Django view to a DRF `@api_view` with `@permission_classes([IsAdmin])`, ensuring the endpoint actually enforces admin-only access. The previous decorator arrangement (`@csrf_exempt` + `@permission_classes`) had no effect on a plain Django view.
|
||||
- Added rate limiting to the login endpoint (`POST /api/accounts/token/`) using DRF's built-in throttling. A `LoginRateThrottle` (3 requests/minute per IP, sliding window) is applied to the `TokenObtainPairView`. Repeated failed attempts from the same IP receive `429 Too Many Requests`.
|
||||
- Extended rate limiting to the session-auth login alias (`POST /api/accounts/auth/login/`). It now delegates entirely to `TokenObtainPairView`, inheriting its throttle, network access check, and audit logging, and returns JWT tokens instead of a session cookie (the session-based response was unusable since `SessionAuthentication` is not in `DEFAULT_AUTHENTICATION_CLASSES`). Both endpoints share the same `"login"` throttle scope, so attempts across either path count against the same per-IP limit.
|
||||
- Removed `CORS_ALLOW_CREDENTIALS = True` from CORS configuration. Dispatcharr authenticates via JWT `Authorization` headers and API keys — not cookies — so credentials are never sent cross-origin by browsers. The setting was also redundant: browsers reject `Access-Control-Allow-Credentials: true` when `Access-Control-Allow-Origin` is a wildcard (`*`), so it had no effect in practice.
|
||||
- Updated frontend npm dependencies to resolve 6 audit vulnerabilities (6 high):
|
||||
- Updated `@xmldom/xmldom` 0.8.11 → 0.8.12, resolving **high** XML injection via unsafe CDATA serialization allowing attacker-controlled markup insertion ([GHSA-wh4c-j3r5-mjhp](https://github.com/advisories/GHSA-wh4c-j3r5-mjhp))
|
||||
- Updated `lodash` 4.17.23 → 4.18.1, resolving **high** Code Injection via `_.template` imports key names ([GHSA-r5fr-rjxr-66jc](https://github.com/advisories/GHSA-r5fr-rjxr-66jc)) and **high** Prototype Pollution via array path bypass in `_.unset` and `_.omit` ([GHSA-f23m-r3pf-42rh](https://github.com/advisories/GHSA-f23m-r3pf-42rh))
|
||||
- Updated `vite` 7.3.1 → 7.3.2, resolving **high** Path Traversal in optimized deps `.map` handling ([GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9)), **high** `server.fs.deny` bypass with queries ([GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r)), and **high** Arbitrary File Read via dev server WebSocket ([GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583))
|
||||
- Updated `Django` 6.0.3 → 6.0.4, resolving the following CVEs:
|
||||
- **CVE-2026-33033**: Potential DoS via `MultiPartParser` through crafted multipart uploads.
|
||||
- **CVE-2026-33034**: SGI requests with a missing or understated `Content-Length` header could bypass the `DATA_UPLOAD_MAX_MEMORY_SIZE` limit.
|
||||
- **CVE-2026-4292**: Privilege abuse in `ModelAdmin.list_editable`.
|
||||
- **CVE-2026-3902**: ASGI header spoofing via underscore/hyphen conflation.
|
||||
- **CVE-2026-4277**: Privilege abuse in `GenericInlineModelAdmin`.
|
||||
|
||||
### Added
|
||||
|
||||
- **EPG historical data window**: the EPG XML output and XC EPG API now support a `prev_days` URL parameter (e.g. `&prev_days=3`) to include past programs in the EPG response. This allows third-party players that request historical program schedules to receive the data they need. The EPG URL builder in the Channels page exposes "Days forward" and "Days back" controls. Per-user defaults for both values (`epg_days` / `epg_prev_days`) can be configured in the User settings modal and are applied automatically when no URL parameter is present. (Closes #1154)
|
||||
- **Plugin Hub**: administrators can now browse, install, and update plugins directly from remote repositories via a new Plugin Hub page in Settings. (Closes #393) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- Install plugins directly from the hub: the release zip is downloaded, SHA256 integrity is verified, and the plugin is installed atomically.
|
||||
- Update managed plugins when a newer version is available from their source repo. Version compatibility constraints (`min_dispatcharr_version` / `max_dispatcharr_version`) are enforced at install time.
|
||||
- Browse available plugins from all enabled repos with name, description, version, author, and icon.
|
||||
- Plugins installed from a repo are tracked as "managed": source repo, slug, installed version, prerelease flag, and deprecated status are all persisted and surfaced in the UI.
|
||||
- Add plugin repositories by manifest URL. The official Dispatcharr Plugins repository is pre-configured; third-party repos are supported by supplying an optional GPG public key.
|
||||
- Manifest signatures are verified via GPG; the official repo uses a bundled public key. Signature status is displayed per-repo.
|
||||
- Preview a repository URL before adding it - validates the manifest and reports plugin count and signature status without saving anything.
|
||||
- Configurable automatic manifest refresh interval (in hours; 0 to disable) runs as a Celery background task.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed dead `VODConnectionManager` class (`apps/proxy/vod_proxy/connection_manager.py`) and its associated helpers, which had been superseded by `MultiWorkerVODConnectionManager`. All active code already used the multi-worker implementation. Removed the unused `VODConnectionManager` import from `vod_proxy/views.py`, the unscheduled `cleanup_vod_connections` task from `apps/proxy/tasks.py`, and the unscheduled `cleanup_vod_persistent_connections` task from `core/tasks.py`.
|
||||
- Removed dead VOD URL routes: `VODPlaylistView` (playlist generation), `VODPositionView` (position tracking), and the class-based `VODStatsView` (replaced by the existing function-based `vod_stats` view).
|
||||
- Removed dead `updateVODPosition()` API method from `frontend/src/api.js`, which called the now-removed position tracking endpoint.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed TV Guide "Record One" always scheduling the recording on the first channel that matched the program's `tvg_id`, rather than the channel the user actually selected. When multiple channels share the same EPG source, the intended channel was silently ignored. The selected channel object is now passed explicitly through the click handler chain to `recordOne`, bypassing the `findChannelByTvgId` fallback lookup entirely. (Fixes #1140) — Thanks [@fezster](https://github.com/fezster)
|
||||
- Graceful container shutdown: `docker stop` no longer results in exit 137 (SIGKILL). The entrypoint now explicitly stops all child processes — including uWSGI workers, Celery, Daphne, and Redis, which are spawned as uWSGI `attach-daemon` children and were previously invisible to the signal handler. A polling loop replaces the old fixed `sleep`, exiting as soon as all processes have stopped (up to an 8-second ceiling before force-stopping). PostgreSQL is stopped using `pg_ctl stop -m immediate` as a fallback rather than SIGKILL to avoid data corruption. Process names are now recorded at startup and displayed correctly in crash diagnostics. The unexpected-exit diagnostic block is now suppressed on normal `docker stop` shutdowns. — Thanks [@Shokkstokk](https://github.com/Shokkstokk) for the initial fix!
|
||||
- Fixed two race conditions in the VOD proxy that caused the `profile_connections` counter to go permanently negative, allowing connections beyond the configured profile limit. (1) `_decrement_profile_connections()` used a GET-before-DECR guard: two concurrent decrements could both read the same positive value, both pass the guard, and both fire, driving the counter below zero. Replaced with an unconditional `DECR` followed by a clamp-to-zero if the result is negative. (2) The `stream_generator` decremented `active_streams` and then checked `has_active_streams()` in two separate Redis round-trips without locking. A concurrent generator on another worker could read `active_streams=0` in the window between those two calls and also decrement the profile counter, producing a double-decrement. A new `decrement_active_streams_and_check()` method performs both operations under a single distributed lock, and a `profile_decremented` flag guards all four call sites in the generator so the profile counter is only ever decremented once per stream. (Closes #1125) — Thanks [@firestaerter3](https://github.com/firestaerter3)
|
||||
- Fixed a provider TCP connection leak in the VOD proxy `stream_generator`. When a stream ended via an unhandled exception path that reached the `finally` block without any of the three exception handlers having run (e.g. an error raised before the first `yield`), the `finally` block decremented counters but never called `redis_connection.cleanup()`. The upstream `requests.Response` and `requests.Session` were left open until garbage collection. The `finally` block now starts a `delayed_cleanup` daemon thread (matching the 1-second delay used by the normal-completion and `GeneratorExit` paths) so that seeking clients have time to reconnect and increment `active_streams` before `cleanup()` checks whether it is safe to close the connection.
|
||||
- Fixed manual stream selection from the Stats page not enforcing M3U profile connection limits in multi-worker deployments. When a non-owning worker handled the `change_stream` request it correctly packaged `stream_id` and `m3u_profile_id` into the Redis pubsub message, but the owning worker's pubsub handler only consumed `url` and `user_agent` silently dropping both IDs before calling `stream_manager.update_url()`. Because `update_url` only calls `update_stream_profile()` when a `stream_id` is provided, the `profile_connections` counter was never updated after the switch, causing subsequent capacity checks to see incorrect counts and bypass the full-profile guard. The handler now extracts `stream_id` and `m3u_profile_id` from the event and forwards them to `update_url()`. The bug did not affect single-worker / dev-mode deployments because the owning worker handles those requests directly without pubsub.
|
||||
- Fixed the `next_stream` rotation endpoint applying the same class of bug: `get_stream_info_for_switch()` was called and returned `m3u_profile_id`, but the result was dropped when forwarding to `ChannelService.change_stream_url()`, so `update_stream_profile()` was never called and `profile_connections` counters were not updated after an automatic stream rotation.
|
||||
- Fixed stream switch metadata (`url`, `user_agent`, `stream_id`, `m3u_profile`) being written to Redis before the switch was confirmed to succeed. If the switch failed, URL unchanged or exception during teardown, Redis described a URL not actually in use. Metadata is now written only after `update_url()` returns `True`; on failure the owner writes `stream_manager.url` back as the ground truth. The non-owner no longer pre-writes metadata at all, all needed info is carried in the pubsub payload and written by the owner after confirmation.
|
||||
- Fixed the Stats page "Active Stream" dropdown not updating when a stream switch occurs. The card was matching the active stream by comparing the URL stored in Redis against stream URLs from the database, which failed silently when the stored URL was a transformed/rewritten value that didn't substring-match the original. The dropdown now matches by `stream_id` (the authoritative value already present in the stats payload) and re-runs only when `stream_id` changes, so the normal polling interval drives updates with no extra renders.
|
||||
- Fixed the XC Password field in the User modal being editable by standard users despite the backend (`PATCH /api/accounts/users/me/`) stripping `xc_password` from `custom_properties` for non-admin users, causing the change to silently revert on save. The field and its generate button are now disabled with an explanatory description when the current user is not an administrator.
|
||||
- Fixed live stream hiccups caused by nginx buffering TS proxy data to disk. The `/proxy/` location block used `proxy_buffering off` and `proxy_read/send_timeout` directives, which are silently ignored when the upstream is `uwsgi_pass` (a different directive family). nginx was therefore defaulting to `uwsgi_buffering on`, spooling stream data through temp files on disk. Replaced with the correct `uwsgi_buffering off`, `uwsgi_read_timeout 300s`, and `uwsgi_send_timeout 300s` directives so stream data flows directly from uWSGI to the client socket without intermediate disk I/O.
|
||||
- Fixed the logo cache endpoint (`/api/channels/logos/{id}/cache/`) holding a uWSGI greenlet indefinitely when fetching from a slow or dripping remote server. The previous implementation used `StreamingHttpResponse(iter_content())` with only a per-chunk read timeout; a server that drips data just fast enough to reset the per-read timer could hold the greenlet open forever. Replaced with an eager read loop enforcing a hard total-download deadline (10 s) and a size cap (5 MB). Also fixed a race condition in the existing negative-cache logic: the failure entry for a URL was cleared immediately upon receiving HTTP 200, before the body was read. A concurrent greenlet seeing no failure entry during a slow download that ultimately timed out would also attempt the fetch, defeating the cache. The entry is now cleared only after the full body has been successfully received.
|
||||
- Fixed uploading a local M3U file with no expiration date set sending the string `"null"` as the `exp_date` field in the `FormData` request, causing a 400 validation error from the API. Null/undefined values are now skipped when building the `FormData` body, matching the behaviour already present in the update path.
|
||||
- Fixed `PATCH /api/channels/channels/edit/bulk/` returning a 500 error when the request body included a `streams` list. The bulk edit handler was iterating `validated_data` directly and calling `setattr(channel, "streams", value)`, which Django prohibits on ManyToMany fields. Also added an `@extend_schema` decorator so the Swagger UI correctly documents the endpoint as accepting a JSON array and shows the `streams` field. (Fixes #883)
|
||||
- Fixed several incorrect or incomplete OpenAPI (`@extend_schema`) schemas across the API:
|
||||
- `POST /api/epg/import/` — request body was undocumented; now correctly shows the `id` field. Description updated from "import" to "refresh" to match frontend and backend terminology.
|
||||
- `DELETE /api/channels/logos/bulk-delete/` — `delete_files` boolean was missing from the documented request body.
|
||||
- `POST /api/channels/channels/batch-set-epg/` — `epg_data_id` inside each association object was not marked `allow_null`/`required=False`, even though passing `null` is the correct way to remove an EPG link.
|
||||
- `PUT /api/connect/integrations/{id}/subscriptions/set/` — endpoint had no `@extend_schema` at all; now documents that the request body is a JSON array of subscription objects.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Output bitrate DB persistence**: the `ffmpeg_output_bitrate` stat is no longer written to the database on every FFmpeg stats tick (~2/second). Instead, a local exponential moving average (EMA, α=0.1) accumulates readings continuously. The first 10 samples (~5 seconds) are discarded as warmup to avoid polluting the average with FFmpeg's unstable ramp-up values. After warmup, the smoothed value is flushed to the database at most once every 30 seconds, and a final flush occurs when the stream stops but only if the EMA has been seeded (i.e. the stream ran past warmup). Streams that stop during warmup leave the existing database value untouched, preserving previously accurate measurements when channel-hopping.
|
||||
- Performance: `generate_m3u`, `generate_epg`, and `xc_get_live_streams` now use `select_related('channel_group', 'logo')` (or `select_related('logo')` for EPG) on every Channel queryset in `apps/output/views.py`. Previously each channel in the loop triggered a separate database query for its `logo` and `channel_group` foreign keys; with the JOIN-based prefetch this is reduced to a single query per request. On deployments with ~2 000 channels, `xc_get_live_streams` response time drops from ~2.5–4 s to ~250–450 ms. (Closes #1127) — Thanks [@xBOBxSAGETx](https://github.com/xBOBxSAGETx)
|
||||
- Performance: `generate_epg` now uses `select_related('epg_data__epg_source')` on all EPG channel querysets, eliminating N+1 database queries for `EPGSource` traversal per channel (~15 s improvement on ~2000-channel deployments; total EPG generation time dropped from ~87 s to ~72 s in benchmarks).
|
||||
- Performance: `xc_get_epg` now uses `select_related('epg_data__epg_source')` on all three channel fetch paths. Previously each request triggered 2 additional queries to resolve `channel.epg_data` and `channel.epg_data.epg_source`.
|
||||
- Performance: `generate_m3u` now uses `prefetch_related` for streams when `?direct=true` is requested, eliminating N+1 stream queries (one per channel) on that code path.
|
||||
- Performance: `EPGGridAPIView` (`apps/epg/api_views.py`) now uses `select_related('epg_data__epg_source')` on the `channels_with_custom_dummy` queryset, eliminating 2 extra queries per channel (for `epg_data` and `epg_source`) in the dummy EPG generation loop.
|
||||
- Performance: `generate_epg` now issues a single cross-channel `ProgramData` bulk query. `.values()` returns plain dicts, bypassing per-row Django model instantiation. Results are consumed in independent 5000-row keyset-paginated chunks. Combined with the `select_related` improvements above, EPG generation time on large deployments is significantly reduced.
|
||||
- Performance: `xc_get_live_streams` no longer calls `ChannelGroup.objects.get_or_create(name="Default Group")` once per null-group channel; replaced with a lazy-initialised closure that executes at most one query regardless of how many ungrouped channels are present.
|
||||
- AIO containers now connect to the internal PostgreSQL instance via a Unix domain socket instead of TCP loopback. Users who have `POSTGRES_HOST` explicitly set to `localhost` or `127.0.0.1` in their compose file are automatically migrated to the socket path; any other explicit value (external host/IP) is left untouched. — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- Improved the EPG response cache key. Previously it was based on the raw query string and username, meaning a user default of `epg_days=7` and an explicit `&days=7` URL parameter produced different cache entries for identical output. The key is now built from all resolved effective parameter values (`days`, `prev_days`, `cachedlogos`, `tvg_id_source`) so semantically equivalent requests always share the same cache entry.
|
||||
- Improved the HDHR, M3U, and EPG URL builder popovers in the Channels table: each popover now opens with a brief intro sentence describing its purpose. Toggle switches were refactored to use Mantine's native `label` and `description` props (replacing the previous manual `Group`/`Stack`/`Text` layout), giving each switch a properly styled description line beneath its label. Switch alignment was also corrected. Toggles now appear on the left with the label and description stacked to the right, consistent with standard Mantine form layout.
|
||||
- Redesigned the User settings modal with a tabbed layout: **Account** (username, email, name, password), **Permissions** (user level, stream limit, channel profiles, mature content filter - admin only), **EPG Defaults** (days forward/back), and **API & XC** (XC password, API key management). Fields are now logically grouped rather than split across two ad-hoc columns.
|
||||
- EPG channel scanning now automatically removes stale `EPGData` entries. tvg-ids that were present in a previous scan but are no longer found in the upstream source, provided they are not mapped to any channel. This prevents unbounded database bloat over time. Entries mapped to at least one channel are always preserved.
|
||||
- Rewrote the M3U line parser as an `iter_m3u_entries` generator that owns the full per-entry state machine. Intermediate directive lines between `#EXTINF` and the stream URL are now handled correctly rather than corrupting the pending entry or being silently misassigned. A `#EXTINF` with no following URL is discarded with a warning instead of carrying over a `url`-less entry into batch processing. Attribute keys are normalised to lowercase during parsing (provider attribute names remain case-insensitive end-to-end). The `#EXTINF` attribute regex is pre-compiled at module load, and attribute lookups use O(1) `dict.get()` instead of linear scans — approximately 10% faster parsing on large M3U files.
|
||||
- Added support for the `#EXTGRP` directive in M3U files. When a `group-title` attribute is absent from the `#EXTINF` line, the value from a following `#EXTGRP:` line is used as the group. An explicit `group-title` attribute always takes priority. (Closes #1088)
|
||||
- Added accumulation of `#EXTVLCOPT` directives per entry. Options are stored as a list under `vlc_opts` inside the stream's `custom_properties`, available for downstream use (e.g. passing VLC-specific options to the player). This is for a planned future enhancement and can also be utlized with the API.
|
||||
- M3U stream name parsing now uses the comma text (the canonical display title per the base `#EXTINF` spec) as the primary stream name, falling back to `tvc-guide-title`, then `tvg-name`, rather than preferring `tvg-name` first. Providers that use `tvg-name` as an EPG key and put the human-readable title after the comma will now display the correct name. Providers that duplicate the same value in both fields are unaffected. (Fixes #1081)
|
||||
- FloatingVideo player: the native video controls (timeline, play/pause, volume) are now hidden by default when a live stream starts and only appear when the user hovers over the player.
|
||||
- Enhanced Swagger UI authorization dialog: registered a custom `OpenApiAuthenticationExtension` for `ApiKeyAuthentication` so drf-spectacular now generates an `ApiKeyAuth (apiKey)` entry alongside `jwtAuth`. Both entries include descriptive text linking to the relevant endpoints (`/api/accounts/token/`, `/api/accounts/api-keys/generate/`, `/api/accounts/api-keys/revoke/`).
|
||||
- Refactored frontend form components (`AccountInfoModal`, `AssignChannelNumbers`, `Channel`, `ChannelBatch`, `ChannelGroup`, `Connection`, `CronBuilder`, `DummyEPG`, and `EPG`) to extract business logic into dedicated utility modules under `src/utils/forms/`. Each extracted module is covered by unit tests. Mantine compound component references (`Table.Tbody`, `Popover.Target`, `Accordion.Item`, etc.) have been updated to use flat named imports. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- Improved the EPG BOM fix from v0.22.1: replaced the `lstrip(b'\xef\xbb\xbf')` / `startswith` approach with `start.find(b'<?xml')`, which locates the XML declaration regardless of any leading bytes BOM, whitespace, or other encoding markers without needing to know what those bytes are.
|
||||
- Dependency updates:
|
||||
- `Django` 6.0.3 → 6.0.4 (security patch; see Security section)
|
||||
- `djangorestframework` 3.16.1 → 3.17.1
|
||||
- `requests` 2.33.0 → 2.33.1
|
||||
- `gevent` 25.9.1 → 26.4.0
|
||||
- `rapidfuzz` 3.14.3 → 3.14.5
|
||||
- `sentence-transformers` 5.3.0 → 5.4.0
|
||||
- `lxml` 6.0.2 → 6.0.3
|
||||
- Added `python-gnupg` for GPG signature verification of official and third-party plugin repository manifests.
|
||||
|
||||
## [0.22.1] - 2026-04-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed EPG sources that emit a UTF-8 BOM (e.g. ErsatzTV, EPGShare, WebGrab+Plus) parsing 0 channels and 0 programmes after the HTML entity fix introduced in v0.22.0. `bytes.lstrip()` only strips ASCII whitespace, leaving the three BOM bytes (`EF BB BF`) in place, so `stripped.startswith(b'<?xml')` returned `False`. The function fell through to the no-declaration branch and prepended the HTML entity DOCTYPE block _before_ the BOM and XML declaration, producing invalid XML that lxml silently discarded under `recover=True`. Fixed by stripping the BOM explicitly before the whitespace strip: `start.lstrip(b'\xef\xbb\xbf').lstrip()`. BOM-free files are unaffected. (Closes #1173) — Thanks [@dwot](https://github.com/dwot) for the fix!
|
||||
|
||||
## [0.22.0] - 2026-04-01
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -144,7 +144,19 @@ Untested code is significantly less likely to be merged.
|
|||
|
||||
- Use Django's `TestCase` for unit/integration tests.
|
||||
- Test files live at `apps/<app>/tests/`.
|
||||
- Run the test suite with: `uv run python manage.py test`
|
||||
- Run the backend test suite with:
|
||||
|
||||
```bash
|
||||
python manage.py test
|
||||
```
|
||||
|
||||
`manage.py` automatically uses `dispatcharr.settings_test`, which creates an empty PostgreSQL database `test_<dbname>` (same engine as production), runs migrations, and rolls back each test in a transaction. Your live VOD/channels data is not used.
|
||||
|
||||
Optional: `TEST_USE_SQLITE=1` for machines without Postgres (some PostgreSQL-only tests skip automatically).
|
||||
|
||||
Tests that exercise Celery task bodies should use `@override_settings(CELERY_TASK_ALWAYS_EAGER=True)` locally. Global eager mode is off because `post_save` signals on M3U/EPG models call `.delay()` and would break `TestCase` transaction isolation.
|
||||
|
||||
- Do **not** override with `--settings=dispatcharr.settings` on a live instance.
|
||||
|
||||
### Frontend
|
||||
|
||||
|
|
|
|||
674
Plugin_repo.md
Normal file
674
Plugin_repo.md
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
# Dispatcharr Plugin Repository Specification
|
||||
|
||||
How to create and host a plugin repository that Dispatcharr can consume.
|
||||
|
||||
For writing plugins themselves, see [Plugins.md](Plugins.md).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Dispatcharr discovers plugins from remote repositories using a two-level manifest system:
|
||||
|
||||
1. **Repo manifest** - a JSON file listing all plugins in the repo with basic metadata.
|
||||
2. **Per-plugin manifest** (optional) - a JSON file per plugin with full version history, checksums, and compatibility info.
|
||||
|
||||
Users add a repo by its manifest URL. Dispatcharr fetches and caches the repo manifest periodically (default: every 6 hours, configurable). The UI displays all plugins from enabled repos in a browsable store.
|
||||
|
||||
---
|
||||
|
||||
## Repo Manifest
|
||||
|
||||
The repo manifest is the entry point. Dispatcharr fetches this URL and caches the response.
|
||||
|
||||
### Minimal Example (no signing)
|
||||
|
||||
```json
|
||||
{
|
||||
"registry_name": "My Plugin Repo",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "my_plugin",
|
||||
"name": "My Plugin",
|
||||
"description": "Does something useful",
|
||||
"author": "Your Name",
|
||||
"latest_version": "1.0.0",
|
||||
"latest_url": "https://example.com/releases/my_plugin-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is the simplest valid repo manifest - one plugin with enough info to show in the store and install.
|
||||
|
||||
### Full Example (with signing)
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"registry_name": "My Plugin Repo",
|
||||
"registry_url": "https://github.com/myorg/my-plugins",
|
||||
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "weather_display",
|
||||
"name": "Weather Display",
|
||||
"description": "Shows weather info on the dashboard",
|
||||
"author": "Acme Labs",
|
||||
"maintainers": ["alice", "bob"],
|
||||
"license": "MIT",
|
||||
"deprecated": false,
|
||||
"repo_url": "https://github.com/acmelabs/dispatcharr-weather",
|
||||
"discord_thread": "https://discord.com/channels/123456/789012",
|
||||
"latest_version": "1.2.5",
|
||||
"last_updated": "2025-01-20T15:30:00Z",
|
||||
"manifest_url": "plugins/weather_display/manifest.json",
|
||||
"latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip",
|
||||
"latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"latest_size": 142,
|
||||
"icon_url": "plugins/weather_display/logo.png",
|
||||
"min_dispatcharr_version": "2.5.0",
|
||||
"max_dispatcharr_version": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
|
||||
}
|
||||
```
|
||||
|
||||
### Accepted Formats
|
||||
|
||||
Dispatcharr accepts two top-level shapes:
|
||||
|
||||
**Wrapped (supports signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": { "plugins": [...], ... },
|
||||
"signature": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Flat (no signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [...],
|
||||
"registry_name": "...",
|
||||
"root_url": "..."
|
||||
}
|
||||
```
|
||||
|
||||
The wrapped format is required for signing. If you don't need signing, the flat format works and is simpler.
|
||||
|
||||
### Name Restrictions
|
||||
|
||||
`registry_name` is required. Dispatcharr rejects repos that are missing it.
|
||||
|
||||
Third-party repos must not use names that could be confused with an official Dispatcharr repo. The following words are blocked in `registry_name` (case-insensitive):
|
||||
|
||||
- "official"
|
||||
- "dispatcharr plugins"
|
||||
- "dispatcharr repo"
|
||||
- "dispatcharr official"
|
||||
|
||||
If the name contains any of these, the repo will be rejected on add and skipped during refresh.
|
||||
|
||||
---
|
||||
|
||||
## Repo Manifest Fields
|
||||
|
||||
### Top-Level Metadata
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
|
||||
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
|
||||
| `root_url` | No | Generic base URL for resolving all relative URLs in plugin entries. Trailing slashes are stripped. Used as the fallback when neither `download_base_url` nor `metadata_base_url` is set. |
|
||||
| `download_base_url` | No | Base URL for resolving relative download URLs (`latest_url` in plugin entries; `url` and `latest_url` inside per-plugin manifest `versions`/`latest`). Overrides `root_url` for download assets when set. |
|
||||
| `metadata_base_url` | No | Base URL for resolving relative metadata URLs (`manifest_url` and `icon_url` in plugin entries). Overrides `root_url` for metadata assets when set. |
|
||||
| `plugins` | **Yes** | Array of plugin entry objects. |
|
||||
|
||||
### Plugin Entry Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
|
||||
| `name` | **Yes** | Human-readable display name. |
|
||||
| `description` | No | Short description shown on the plugin card. |
|
||||
| `author` | No | Author or organization name. |
|
||||
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
|
||||
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
|
||||
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
|
||||
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
|
||||
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
|
||||
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
|
||||
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
|
||||
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
|
||||
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
|
||||
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
|
||||
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
|
||||
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
|
||||
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
|
||||
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
|
||||
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
|
||||
|
||||
Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything.
|
||||
|
||||
### URL Resolution
|
||||
|
||||
Relative URL fields are resolved against a base URL. Dispatcharr uses two separate base URLs (one for metadata assets and one for download assets) so you can serve them from different origins (e.g., manifests and icons on GitHub Pages, release zips on a CDN).
|
||||
|
||||
**Resolution priority:**
|
||||
|
||||
| Field(s) | Priority |
|
||||
| --------- | -------- |
|
||||
| `manifest_url`, `icon_url` | `metadata_base_url` → `root_url` |
|
||||
| `latest_url` (plugin entries); `url`, `latest_url` (per-plugin manifest versions/latest) | `download_base_url` → `root_url` |
|
||||
|
||||
A field value is treated as relative if it does not start with `http://` or `https://`. Relative values are resolved as `{base_url}/{field_value}`. All base URL fields are optional; if none are set, URL fields must be absolute.
|
||||
|
||||
**Single base URL (simplest):** use `root_url` for everything:
|
||||
|
||||
```json
|
||||
{
|
||||
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "my_plugin",
|
||||
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip",
|
||||
"icon_url": "plugins/my_plugin/logo.png",
|
||||
"manifest_url": "plugins/my_plugin/manifest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Split base URLs:** use `metadata_base_url` and `download_base_url` when assets are served from different origins:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata_base_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
|
||||
"download_base_url": "https://cdn.example.com/releases",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "my_plugin",
|
||||
"manifest_url": "plugins/my_plugin/manifest.json",
|
||||
"icon_url": "plugins/my_plugin/logo.png",
|
||||
"latest_url": "my_plugin/my_plugin-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can also combine `root_url` with one specific field. The specific field overrides for its consumers, and `root_url` covers the rest:
|
||||
|
||||
```json
|
||||
{
|
||||
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
|
||||
"download_base_url": "https://cdn.example.com/releases"
|
||||
}
|
||||
```
|
||||
|
||||
**Icon fallback:** If `icon_url` is missing, Dispatcharr tries two fallbacks in order:
|
||||
|
||||
1. **Manifest-directory fallback**: if a base URL is set (`root_url`, `metadata_base_url`, etc.) and `manifest_url` is present, the logo is assumed to live in the same directory as the per-plugin manifest:
|
||||
```
|
||||
{directory of resolved manifest_url}/logo.png
|
||||
```
|
||||
For example, if `manifest_url` resolves to `https://example.com/plugins/my_plugin/manifest.json`, the fallback icon URL is `https://example.com/plugins/my_plugin/logo.png`.
|
||||
|
||||
2. **GitHub fallback**: if `registry_url` is a GitHub URL, Dispatcharr converts it to a raw content URL:
|
||||
```
|
||||
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Per-Plugin Manifest (Optional)
|
||||
|
||||
The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead.
|
||||
|
||||
Include a per-plugin manifest if you want to:
|
||||
|
||||
- Offer multiple downloadable versions
|
||||
- Show per-version compatibility ranges
|
||||
- Display build timestamps and commit links for each version
|
||||
- Provide detailed author/license info beyond what's in the repo manifest
|
||||
|
||||
### Accepted Formats
|
||||
|
||||
Same as the root manifest - both flat and wrapped formats are accepted:
|
||||
|
||||
**Flat (no signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "...",
|
||||
"versions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Wrapped (supports signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"slug": "...",
|
||||
"versions": [...]
|
||||
},
|
||||
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
|
||||
}
|
||||
```
|
||||
|
||||
Use the wrapped format if you want to GPG-sign the per-plugin manifest.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "weather_display",
|
||||
"name": "Weather Display",
|
||||
"description": "Shows weather information on the Dispatcharr dashboard",
|
||||
"author": "Acme Labs",
|
||||
"license": "MIT",
|
||||
"latest_version": "1.2.5",
|
||||
"registry_name": "Acme Labs Plugins",
|
||||
"registry_url": "https://github.com/acmelabs/dispatcharr-plugins",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"url": "releases/weather_display-1.2.5.zip",
|
||||
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 142,
|
||||
"build_timestamp": "2025-01-20T15:30:00Z",
|
||||
"commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648",
|
||||
"commit_sha_short": "4e8f1b1",
|
||||
"min_dispatcharr_version": "2.5.0",
|
||||
"max_dispatcharr_version": null
|
||||
},
|
||||
{
|
||||
"version": "1.2.5-rc.1",
|
||||
"url": "releases/weather_display-1.2.5-rc.1.zip",
|
||||
"checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
"size": 141,
|
||||
"prerelease": true,
|
||||
"build_timestamp": "2025-01-18T09:00:00Z",
|
||||
"min_dispatcharr_version": "2.5.0"
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"url": "releases/weather_display-1.2.4.zip",
|
||||
"checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6",
|
||||
"size": 138,
|
||||
"build_timestamp": "2025-01-15T10:00:00Z",
|
||||
"min_dispatcharr_version": "2.4.0"
|
||||
}
|
||||
],
|
||||
"latest": {
|
||||
"version": "1.2.5",
|
||||
"url": "releases/weather_display-1.2.5.zip",
|
||||
"latest_url": "releases/weather_display-latest.zip",
|
||||
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 142,
|
||||
"build_timestamp": "2025-01-20T15:30:00Z",
|
||||
"min_dispatcharr_version": "2.5.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-Plugin Manifest Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `slug` | No | Plugin identifier (should match the repo entry). |
|
||||
| `name` | No | Display name. |
|
||||
| `description` | No | Full description shown in the detail modal. |
|
||||
| `author` | No | Author/org name shown in the detail modal. |
|
||||
| `license` | No | SPDX license identifier. |
|
||||
| `latest_version` | No | Latest version string. |
|
||||
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
|
||||
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
|
||||
| `versions` | No | Array of version objects (newest first recommended). |
|
||||
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
|
||||
|
||||
### Version Object Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
|
||||
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
|
||||
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
|
||||
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
|
||||
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
|
||||
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
|
||||
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
|
||||
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
|
||||
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
|
||||
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
|
||||
|
||||
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
|
||||
|
||||
---
|
||||
|
||||
## Without a Per-Plugin Manifest
|
||||
|
||||
If you omit `manifest_url` from a plugin entry, the store still works. When a user clicks "More Info", the UI builds a detail view from the repo-level fields:
|
||||
|
||||
- `description`, `author`, `license` from the plugin entry
|
||||
- A single version entry built from `latest_version`, `latest_url`, `latest_sha256`, `min_dispatcharr_version`, `max_dispatcharr_version`, and `last_updated`
|
||||
|
||||
This is the simplest path for third-party repos that only publish one version at a time. You lose version history and per-version release dates, but install, update detection, and everything else works the same.
|
||||
|
||||
---
|
||||
|
||||
## Signing
|
||||
|
||||
Signing your repo manifest lets Dispatcharr verify it hasn't been tampered with. Signing is **optional** - unsigned repos work fine but show an "unverified" badge in the UI.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. You generate a GPG keypair.
|
||||
2. You sign the manifest JSON and include the detached signature in the response.
|
||||
3. When adding the repo in Dispatcharr, the user pastes your public key.
|
||||
4. Dispatcharr verifies the signature on every manifest fetch.
|
||||
|
||||
### Key Format
|
||||
|
||||
Standard PGP/GPG armored keys:
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBG...
|
||||
...
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
|
||||
### Signing Convention
|
||||
|
||||
The signature is computed over the **canonical JSON** representation of the `manifest` object (not the entire response), plus a trailing newline:
|
||||
|
||||
```bash
|
||||
# Canonical format: compact JSON (no spaces) + trailing newline
|
||||
jq -c '.manifest' manifest.json | gpg --armor --detach-sign
|
||||
```
|
||||
|
||||
In code terms:
|
||||
|
||||
```python
|
||||
import json
|
||||
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
|
||||
```
|
||||
|
||||
> **Important:** The signing input must be `json.dumps(obj, separators=(",", ":")) + "\n"` - compact JSON with no whitespace, followed by exactly one newline. Any difference (pretty-printing, trailing spaces, key ordering changes) will cause verification to fail.
|
||||
|
||||
### Manifest Structure for Signing
|
||||
|
||||
Use the wrapped format so the signature sits alongside the manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"registry_name": "...",
|
||||
"plugins": [...]
|
||||
},
|
||||
"signature": "-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----"
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Results
|
||||
|
||||
| Result | Meaning | UI Badge |
|
||||
| ------- | ------------------------------------------------------------------- | --------------- |
|
||||
| `true` | Valid signature | Green checkmark |
|
||||
| `false` | Invalid signature or verification error | Red X |
|
||||
| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral |
|
||||
|
||||
### Signing Workflow Example
|
||||
|
||||
```bash
|
||||
# Generate a keypair (one-time)
|
||||
gpg --gen-key
|
||||
|
||||
# Export your public key (give this to repo users)
|
||||
gpg --armor --export "your@email.com" > my-repo.pub
|
||||
|
||||
# Build your manifest
|
||||
cat > manifest.json << 'EOF'
|
||||
{
|
||||
"manifest": {
|
||||
"registry_name": "My Repo",
|
||||
"root_url": "https://example.com/releases",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "my_plugin",
|
||||
"name": "My Plugin",
|
||||
"latest_version": "1.0.0",
|
||||
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Sign the manifest object (canonical JSON + newline)
|
||||
jq -c '.manifest' manifest.json | gpg --armor --detach-sign > manifest.sig
|
||||
|
||||
# Combine into final output
|
||||
jq --arg sig "$(cat manifest.sig)" '.signature = $sig' manifest.json > signed_manifest.json
|
||||
```
|
||||
|
||||
### Third-Party Key Management
|
||||
|
||||
When a user adds your repo URL, they can paste your public key. Dispatcharr stores the key per-repo and uses it for verification. Users can update the key at any time from the repo management UI.
|
||||
|
||||
If you don't provide a key and the repo is not the official Dispatcharr repo, signature verification is skipped (result: `null`).
|
||||
|
||||
---
|
||||
|
||||
## Release Zip Format
|
||||
|
||||
Each plugin release is a `.zip` archive.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Must contain a `plugin.py` with a `Plugin` class, **or** a Python package with `__init__.py` exporting a `Plugin` class.
|
||||
- Files can be at the top level of the zip or inside a single subdirectory.
|
||||
- Optionally include `plugin.json` for metadata discovery without code execution.
|
||||
- Optionally include `logo.png` for the plugin icon.
|
||||
|
||||
### Size Limits
|
||||
|
||||
- Maximum 2000 files per archive.
|
||||
- Maximum total size: 200 MB (configurable via `MAX_PLUGIN_IMPORT_BYTES` setting).
|
||||
|
||||
### Recommended Structure
|
||||
|
||||
```
|
||||
my_plugin-1.0.0.zip
|
||||
plugin.py
|
||||
plugin.json
|
||||
logo.png
|
||||
(any other files your plugin needs)
|
||||
```
|
||||
|
||||
Or with a subdirectory:
|
||||
|
||||
```
|
||||
my_plugin-1.0.0.zip
|
||||
my_plugin/
|
||||
plugin.py
|
||||
plugin.json
|
||||
logo.png
|
||||
utils.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Install Flow
|
||||
|
||||
When a user installs a plugin from the store:
|
||||
|
||||
1. **Version compatibility check** - if `min_dispatcharr_version` or `max_dispatcharr_version` is set, the running Dispatcharr version is compared. Install is blocked if out of range.
|
||||
2. **Download** - the zip is streamed from `download_url` (max 200 MB).
|
||||
3. **SHA256 integrity check** - if `sha256` was provided, the download is hashed and compared. Mismatch blocks the install.
|
||||
4. **Extraction** - the zip is extracted to a temp directory, validated, then moved to `/data/plugins/{plugin_key}/`. If the plugin already exists, the old version is backed up and restored on failure (atomic rollback).
|
||||
5. **Registration** - a `PluginConfig` record is created or updated, linking the plugin to its source repo and slug.
|
||||
6. **Discovery reload** - the plugin loader re-scans all plugin directories.
|
||||
|
||||
The plugin is installed **disabled** by default. The user can enable it from the post-install dialog or the My Plugins page.
|
||||
|
||||
---
|
||||
|
||||
## Update Detection
|
||||
|
||||
Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection.
|
||||
|
||||
A plugin shows "Update Available" when:
|
||||
|
||||
- It is managed (installed from a repo)
|
||||
- Its `installed_version` differs from `latest_version`
|
||||
- It was installed from the same repo
|
||||
|
||||
---
|
||||
|
||||
## Hosting Options
|
||||
|
||||
A plugin repo manifest is just a JSON file served over HTTPS. Some options:
|
||||
|
||||
### GitHub Pages / Raw Content
|
||||
|
||||
Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
|
||||
```
|
||||
|
||||
Use `root_url` pointing to your releases branch/path so version URLs stay relative.
|
||||
|
||||
### Static File Server
|
||||
|
||||
Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed.
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch.
|
||||
|
||||
---
|
||||
|
||||
## Refresh Behavior
|
||||
|
||||
- Manifests are refreshed automatically at a configurable interval (default: 6 hours, setting: `refresh_interval_hours`, 0 = disabled).
|
||||
- Users can force a refresh from the repo management UI.
|
||||
- A new repo is refreshed immediately when added.
|
||||
- On refresh, if a plugin's `slug` disappears from the manifest, its `PluginConfig` is unlinked from the repo (becomes "unmanaged") but the installed files are not deleted.
|
||||
|
||||
---
|
||||
|
||||
## Checklist: Publishing a Plugin Repo
|
||||
|
||||
### Minimum Viable Repo
|
||||
|
||||
- [ ] Host a JSON file at a stable, public URL
|
||||
- [ ] Set `registry_name` (required, must not sound official)
|
||||
- [ ] Include at least one plugin entry with `slug`, `name`, and `latest_version`
|
||||
- [ ] Host a downloadable `.zip` for each plugin and set `latest_url`
|
||||
- [ ] Share the manifest URL with users
|
||||
|
||||
### Recommended
|
||||
|
||||
- [ ] Set `root_url` so plugin URLs can be relative
|
||||
- [ ] Include `description`, `author`, and `icon_url` per plugin
|
||||
- [ ] Include `latest_sha256` for integrity verification
|
||||
- [ ] Include `license` (SPDX identifier)
|
||||
- [ ] Include `last_updated` timestamps
|
||||
- [ ] Add a per-plugin `manifest_url` with version history
|
||||
- [ ] Include `sha256` in every version object
|
||||
- [ ] Include `min_dispatcharr_version` where applicable
|
||||
- [ ] Include `plugin.json` in each release zip
|
||||
|
||||
### Optional
|
||||
|
||||
- [ ] Sign your manifest with GPG and publish your public key
|
||||
- [ ] Set `registry_url` to enable automatic icon fallback
|
||||
- [ ] Set `max_dispatcharr_version` if a plugin is incompatible with newer releases
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Repo Manifest Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"registry_name": "string (required)",
|
||||
"registry_url": "string (optional)",
|
||||
"root_url": "string (optional, generic base URL fallback)",
|
||||
"download_base_url": "string (optional, overrides root_url for zip download URLs)",
|
||||
"metadata_base_url": "string (optional, overrides root_url for manifest_url and icon_url)",
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "string (required)",
|
||||
"name": "string (required)",
|
||||
"description": "string",
|
||||
"author": "string",
|
||||
"maintainers": ["string"],
|
||||
"license": "string (SPDX)",
|
||||
"deprecated": "boolean",
|
||||
"repo_url": "string (URL)",
|
||||
"discord_thread": "string (URL)",
|
||||
"latest_version": "string (semver)",
|
||||
"last_updated": "string (ISO 8601)",
|
||||
"manifest_url": "string (URL or relative path)",
|
||||
"latest_url": "string (URL or relative path)",
|
||||
"latest_sha256": "string (64-char hex)",
|
||||
"latest_md5": "string",
|
||||
"latest_size": "number (KB)",
|
||||
"icon_url": "string (URL or relative path)",
|
||||
"min_dispatcharr_version": "string (semver)",
|
||||
"max_dispatcharr_version": "string (semver) or null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"signature": "string (armored PGP signature, optional)"
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference: Per-Plugin Manifest Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"author": "string",
|
||||
"license": "string (SPDX)",
|
||||
"latest_version": "string (semver)",
|
||||
"registry_name": "string",
|
||||
"registry_url": "string (URL)",
|
||||
"versions": [
|
||||
{
|
||||
"version": "string (required)",
|
||||
"url": "string (required, URL or relative path)",
|
||||
"checksum_sha256": "string (64-char hex)",
|
||||
"size": "number (KB)",
|
||||
"prerelease": "boolean",
|
||||
"build_timestamp": "string (ISO 8601)",
|
||||
"commit_sha": "string",
|
||||
"commit_sha_short": "string",
|
||||
"min_dispatcharr_version": "string (semver)",
|
||||
"max_dispatcharr_version": "string (semver) or null"
|
||||
}
|
||||
],
|
||||
"latest": {
|
||||
"version": "string",
|
||||
"url": "string",
|
||||
"latest_url": "string (stable symlink URL)",
|
||||
"checksum_sha256": "string",
|
||||
"size": "number (KB)",
|
||||
"build_timestamp": "string",
|
||||
"min_dispatcharr_version": "string",
|
||||
"max_dispatcharr_version": "string or null"
|
||||
}
|
||||
}
|
||||
```
|
||||
12
Plugins.md
12
Plugins.md
|
|
@ -307,6 +307,18 @@ Plugins are server-side Python code running within the Django application. You c
|
|||
|
||||
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
|
||||
|
||||
### Database connections
|
||||
|
||||
Dispatcharr uses `django-db-geventpool` with a bounded per-uWSGI-worker pool (`MAX_CONNS=8`). Each greenlet or OS thread that runs ORM code checks out a connection until Django closes it.
|
||||
|
||||
`PluginManager.run_action()` and `stop_plugin()` always call `close_old_connections()` in a `finally` block after your plugin returns (success or error). That returns the current greenlet's checkout to the pool. **You do not need to call `close_old_connections()` yourself for normal inline ORM inside `run()` or `stop()`.**
|
||||
|
||||
Still follow these rules:
|
||||
|
||||
- **Heavy or long work:** dispatch a Celery task (`.delay()`) and return quickly from `run()`. Celery workers close connections after each task; blocking the uWSGI gevent hub with `time.sleep`, sync HTTP, or large CPU work can freeze the whole worker regardless of DB cleanup.
|
||||
- **Background threads or greenlets you spawn:** each thread/greenlet that uses the ORM must call `close_old_connections()` (or `connection.close()`) in its own `finally` block when done. The wrapper only covers the thread/greenlet that called `run_action()`.
|
||||
- **Connect event hooks:** actions with an `"events"` list are dispatched from `log_system_event()` on a separate gevent when uWSGI has an active hub (otherwise synchronously, e.g. Celery). Keep handlers short or defer heavy work to Celery.
|
||||
|
||||
### Important: Don’t Ask Users for URL/User/Password
|
||||
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities.
|
||||
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Use HDHomeRun emulation to add virtual tuners to **Plex**, **Emby**, or **Jellyf
|
|||
Merge live TV channels with custom EPG guides. Generate XMLTV schedules or use auto-matching to align channels with existing program data. Export as M3U, Xtream Codes API, or HDHomeRun device.
|
||||
|
||||
🔧 **Transcode & Optimize Streams**\
|
||||
Configure output profiles with FFmpeg transcoding to optimize streams for different clients — reduce bandwidth, standardize formats, or add audio normalization.
|
||||
Configure output profiles with FFmpeg transcoding to optimize streams for different clients (reduce bandwidth, standardize formats, or add audio normalization).
|
||||
|
||||
🔐 **Centralize VPN Access**\
|
||||
Run Dispatcharr through a VPN container (like Gluetun) so all streams route through a single VPN connection. Your clients access geo-blocked content without needing individual VPNs, reducing bandwidth overhead and simplifying network management.
|
||||
|
|
@ -53,8 +53,8 @@ Build custom integrations using Dispatcharr's robust plugin system. Automate tas
|
|||
✅ **Video on Demand** — Stream movies and TV series with rich metadata and IMDB/TMDB integration\
|
||||
✅ **Multi-Format Output** — Export as M3U, XMLTV EPG, Xtream Codes API, or HDHomeRun device\
|
||||
✅ **Real-Time Monitoring** — Live connection stats, bandwidth tracking, and automatic failover\
|
||||
✅ **Stream Profiles** — Configure different stream profiles for various clients and bandwidth requirements\
|
||||
✅ **Flexible Streaming Backends** — VLC, FFmpeg, Streamlink, or custom backends for transcoding and streaming\
|
||||
✅ **Stream Profiles** — Configure how Dispatcharr connects to backend streams (VLC, FFmpeg, Streamlink, or custom commands)\
|
||||
✅ **Output Profiles** — Transcode what stream profiles deliver before it reaches the client (e.g. AC3 for media servers, AAC for browsers) with fMP4 or MPEG-TS container selection\
|
||||
✅ **Multi-User & Access Control** — Granular permissions and network-based access restrictions\
|
||||
✅ **Plugin System** — Extend functionality with custom plugins for automation and integrations\
|
||||
✅ **Fully Self-Hosted** — Total control, no third-party dependencies
|
||||
|
|
@ -135,7 +135,6 @@ We welcome **PRs, issues, ideas, and suggestions**!
|
|||
- 🎬 **VOD Management Enhancements** — Granular metadata control and cleanup of unwanted VOD content
|
||||
- 📁 **Media Library** — Import local files and serve them over XC API
|
||||
- 👥 **Enhanced User Management** — Customizable XC API output per user account
|
||||
- 🔄 **Output Stream Profiles** — Different clients with different stream profiles (bandwidth control, quality tiers)
|
||||
- 🔌 **Fallback Videos** — Automatic fallback content when channels are unavailable
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
import json
|
||||
|
|
@ -20,9 +21,14 @@ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRateThrottle(AnonRateThrottle):
|
||||
scope = "login"
|
||||
|
||||
|
||||
class TokenObtainPairView(TokenObtainPairView):
|
||||
throttle_classes = [LoginRateThrottle]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Custom logic here
|
||||
if not network_access_allowed(request, "UI"):
|
||||
# Log blocked login attempt due to network restrictions
|
||||
from core.utils import log_system_event
|
||||
|
|
@ -153,12 +159,11 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
Login doesn't require auth, but logout does
|
||||
"""
|
||||
if self.action == 'logout':
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
return [IsAuthenticated()]
|
||||
return [Authenticated()]
|
||||
return []
|
||||
|
||||
@extend_schema(
|
||||
description="Authenticate and log in a user",
|
||||
description="Alias for POST /api/accounts/token/ — returns JWT access and refresh tokens.",
|
||||
request=inline_serializer(
|
||||
name="LoginRequest",
|
||||
fields={
|
||||
|
|
@ -168,55 +173,10 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
),
|
||||
)
|
||||
def login(self, request):
|
||||
"""Logs in a user and returns user details"""
|
||||
username = request.data.get("username")
|
||||
password = request.data.get("password")
|
||||
user = authenticate(request, username=username, password=password)
|
||||
|
||||
# Get client info for logging
|
||||
from core.utils import log_system_event
|
||||
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
logger.debug(f"Login attempt via session: user={username} ip={client_ip}")
|
||||
|
||||
if user:
|
||||
login(request, user)
|
||||
# Update last_login timestamp
|
||||
from django.utils import timezone
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
# Log successful login
|
||||
log_system_event(
|
||||
event_type='login_success',
|
||||
user=username,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Login success via session: user={username} ip={client_ip}")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Login successful",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"groups": list(user.groups.values_list("name", flat=True)),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Log failed login attempt
|
||||
log_system_event(
|
||||
event_type='login_failed',
|
||||
user=username or 'unknown',
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
reason='Invalid credentials',
|
||||
)
|
||||
logger.info(f"Login failed via session: user={username} ip={client_ip}")
|
||||
return Response({"error": "Invalid credentials"}, status=400)
|
||||
"""Delegates to TokenObtainPairView (JWT login). Throttling, logging, and
|
||||
network access checks are handled there."""
|
||||
view = TokenObtainPairView.as_view()
|
||||
return view(request._request)
|
||||
|
||||
@extend_schema(
|
||||
description="Log out the current user",
|
||||
|
|
@ -291,6 +251,14 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
for key in disallowed:
|
||||
request.data.pop(key, None)
|
||||
|
||||
# Strip admin-managed keys from custom_properties so users cannot
|
||||
# set their own XC credentials or network rules via this endpoint.
|
||||
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks"}
|
||||
cp = request.data.get("custom_properties")
|
||||
if isinstance(cp, dict):
|
||||
for key in ADMIN_ONLY_PROPS:
|
||||
cp.pop(key, None)
|
||||
|
||||
serializer = UserSerializer(user, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,48 @@
|
|||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from django.conf import settings
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from .models import User
|
||||
|
||||
|
||||
class JWTAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "rest_framework_simplejwt.authentication.JWTAuthentication"
|
||||
name = "jwtAuth"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": (
|
||||
"JWT Bearer authentication.\n\n"
|
||||
"Obtain a token pair via `POST /api/accounts/token/` using your username and password, "
|
||||
"then paste the **access token** here — Swagger adds the `Bearer ` prefix automatically.\n\n"
|
||||
"Access tokens expire after 30 minutes. Refresh using `POST /api/accounts/token/refresh/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "apps.accounts.authentication.ApiKeyAuthentication"
|
||||
name = "ApiKeyAuth"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": (
|
||||
"API key authentication.\n\n"
|
||||
"Pass your personal API key in the `X-API-Key` request header. "
|
||||
"Keys can be generated via `POST /api/accounts/api-keys/generate/` "
|
||||
"and revoked via `POST /api/accounts/api-keys/revoke/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyAuthentication(authentication.BaseAuthentication):
|
||||
"""
|
||||
Accepts header `Authorization: ApiKey <key>` or `X-API-Key: <key>`.
|
||||
|
|
@ -47,3 +86,19 @@ class ApiKeyAuthentication(authentication.BaseAuthentication):
|
|||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class QueryParamJWTAuthentication(JWTAuthentication):
|
||||
"""Reads a JWT from the `token` query parameter. Used for media endpoints
|
||||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||
|
||||
def authenticate(self, request):
|
||||
params = getattr(request, "query_params", request.GET)
|
||||
raw_token = params.get("token")
|
||||
if not raw_token:
|
||||
return None
|
||||
try:
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
except (InvalidToken, TokenError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ from dispatcharr.utils import network_access_allowed
|
|||
class Authenticated(IsAuthenticated):
|
||||
def has_permission(self, request, view):
|
||||
is_authenticated = super().has_permission(request, view)
|
||||
network_allowed = network_access_allowed(request, "UI")
|
||||
user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None
|
||||
network_allowed = network_access_allowed(request, "UI", user)
|
||||
|
||||
return is_authenticated and network_allowed
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ VALID_NAV_ITEM_IDS = {
|
|||
'channels', 'vods', 'sources', 'guide', 'dvr',
|
||||
'stats', 'plugins', 'integrations', 'system', 'settings'
|
||||
}
|
||||
MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit
|
||||
MAX_CUSTOM_PROPS_SIZE = 102400 # 100KB limit
|
||||
|
||||
|
||||
def validate_nav_array(value, field_name):
|
||||
|
|
@ -116,12 +116,16 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
channel_profiles = validated_data.pop("channel_profiles", None)
|
||||
|
||||
# Merge custom_properties instead of replacing (prevents data loss)
|
||||
# Strip null values — sending null for a key omits it rather than overwriting with null
|
||||
# null values are explicit deletions; all other values overwrite existing
|
||||
custom_properties = validated_data.pop("custom_properties", None)
|
||||
if custom_properties is not None:
|
||||
existing = instance.custom_properties or {}
|
||||
cleaned = {k: v for k, v in custom_properties.items() if v is not None}
|
||||
merged = {**existing, **cleaned}
|
||||
merged = dict(existing)
|
||||
for k, v in custom_properties.items():
|
||||
if v is None:
|
||||
merged.pop(k, None)
|
||||
else:
|
||||
merged[k] = v
|
||||
# Scrub stale nav IDs so the DB self-heals on next save
|
||||
for nav_field in ('navOrder', 'hiddenNav'):
|
||||
if nav_field in merged and isinstance(merged[nav_field], list):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from rest_framework.permissions import AllowAny
|
|||
from apps.accounts.permissions import IsAdmin
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.response import Response
|
||||
from core.utils import safe_upload_path
|
||||
|
||||
from . import services
|
||||
from .tasks import create_backup_task, restore_backup_task
|
||||
|
|
@ -267,10 +268,18 @@ def upload_backup(request):
|
|||
|
||||
try:
|
||||
backup_dir = services.get_backup_dir()
|
||||
filename = uploaded.name or "uploaded-backup.zip"
|
||||
# Sanitize filename: strip directory components to prevent path traversal
|
||||
filename = Path(uploaded.name or "uploaded-backup.zip").name
|
||||
if not filename:
|
||||
filename = "uploaded-backup.zip"
|
||||
|
||||
try:
|
||||
safe_upload_path(filename, str(backup_dir))
|
||||
except ValueError:
|
||||
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Ensure unique filename
|
||||
backup_file = backup_dir / filename
|
||||
backup_file = (backup_dir / filename).resolve()
|
||||
counter = 1
|
||||
while backup_file.exists():
|
||||
name_parts = filename.rsplit(".", 1)
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ def get_backup_dir() -> Path:
|
|||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
"""Check if we're using PostgreSQL."""
|
||||
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
|
||||
return "postgresql" in settings.DATABASES["default"]["ENGINE"]
|
||||
|
||||
|
||||
def _get_pg_env() -> dict:
|
||||
|
|
@ -171,30 +170,25 @@ def _restore_postgresql(dump_file: Path) -> None:
|
|||
|
||||
|
||||
def _dump_sqlite(output_file: Path) -> None:
|
||||
"""Dump SQLite database using sqlite3 .backup command."""
|
||||
logger.info("Dumping SQLite database with sqlite3 .backup...")
|
||||
import sqlite3 as _sqlite3
|
||||
logger.info("Dumping SQLite database...")
|
||||
db_path = Path(settings.DATABASES["default"]["NAME"])
|
||||
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {db_path}")
|
||||
|
||||
# Use sqlite3 .backup command via stdin for reliable execution
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=f".backup '{output_file}'\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
src = _sqlite3.connect(str(db_path))
|
||||
dst = _sqlite3.connect(str(output_file))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 backup failed: {result.stderr}")
|
||||
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
|
||||
|
||||
# Verify the backup file was created
|
||||
if not output_file.exists():
|
||||
raise RuntimeError("sqlite3 backup failed: output file not created")
|
||||
raise RuntimeError("SQLite backup failed: output file not created")
|
||||
|
||||
logger.info(f"sqlite3 backup completed successfully: {output_file}")
|
||||
logger.info(f"SQLite backup completed successfully: {output_file}")
|
||||
|
||||
|
||||
def _restore_sqlite(dump_file: Path) -> None:
|
||||
|
|
@ -216,23 +210,20 @@ def _restore_sqlite(dump_file: Path) -> None:
|
|||
# We can simply copy it over the existing database
|
||||
shutil.copy2(dump_file, db_path)
|
||||
|
||||
# Verify the restore worked by checking if sqlite3 can read it
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=".tables\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 verification failed: {result.stderr}")
|
||||
# Try to restore from backup
|
||||
# Verify the restore worked by checking if the file is a readable SQLite database
|
||||
import sqlite3 as _sqlite3
|
||||
try:
|
||||
conn = _sqlite3.connect(str(db_path))
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
except _sqlite3.DatabaseError as exc:
|
||||
logger.error(f"SQLite verification failed: {exc}")
|
||||
if backup_current and backup_current.exists():
|
||||
shutil.copy2(backup_current, db_path)
|
||||
logger.info("Restored original database from backup")
|
||||
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
|
||||
raise RuntimeError(f"SQLite restore verification failed: {exc}") from exc
|
||||
|
||||
logger.info("sqlite3 restore completed successfully")
|
||||
logger.info("SQLite restore completed successfully")
|
||||
|
||||
|
||||
def create_backup() -> Path:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import traceback
|
||||
from celery import shared_task
|
||||
from django.core.management import call_command
|
||||
|
||||
from . import services
|
||||
|
||||
|
|
@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str):
|
|||
backup_file = backup_dir / filename
|
||||
logger.info(f"[RESTORE] Backup file path: {backup_file}")
|
||||
services.restore_backup(backup_file)
|
||||
logger.info(f"[RESTORE] Running migrations after restore...")
|
||||
call_command('migrate', '--noinput', verbosity=1)
|
||||
logger.info(f"[RESTORE] Task {self.request.id} completed successfully")
|
||||
return {
|
||||
"status": "completed",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ from .api_views import (
|
|||
UpdateChannelMembershipAPIView,
|
||||
BulkUpdateChannelMembershipAPIView,
|
||||
RecordingViewSet,
|
||||
RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
RecurringRecordingRuleViewSet,
|
||||
GetChannelStreamsAPIView,
|
||||
GetChannelStreamStatsAPIView,
|
||||
SeriesRulesAPIView,
|
||||
DeleteSeriesRuleAPIView,
|
||||
SeriesRulePreviewAPIView,
|
||||
EvaluateSeriesRulesAPIView,
|
||||
BulkRemoveSeriesRecordingsAPIView,
|
||||
BulkDeleteUpcomingRecordingsAPIView,
|
||||
|
|
@ -41,14 +43,23 @@ urlpatterns = [
|
|||
path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'),
|
||||
path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'),
|
||||
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
|
||||
path('channels/<int:channel_id>/streams/stats/', GetChannelStreamStatsAPIView.as_view(), name='get_channel_stream_stats'),
|
||||
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
|
||||
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
|
||||
# DVR series rules (order matters: specific routes before catch-all slug)
|
||||
path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'),
|
||||
path('series-rules/preview/', SeriesRulePreviewAPIView.as_view(), name='series_rules_preview'),
|
||||
path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'),
|
||||
path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'),
|
||||
path('series-rules/<path:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'),
|
||||
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
|
||||
path(
|
||||
'recordings/<int:pk>/hls/<path:seg_path>',
|
||||
RecordingViewSet.as_view(
|
||||
{'get': 'hls'},
|
||||
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
),
|
||||
name='recording-hls',
|
||||
),
|
||||
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
|
||||
]
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
429
apps/channels/compact_numbering.py
Normal file
429
apps/channels/compact_numbering.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"""Compact channel numbering helpers.
|
||||
|
||||
The per-group `compact_numbering` custom_property is opt-in. When enabled
|
||||
on a ChannelGroupM3UAccount, the group's auto-created channels get packed
|
||||
contiguously into the group's [start, end] range:
|
||||
|
||||
* Visible without channel_number override: assigned sequentially
|
||||
* Hidden without channel_number override: channel_number set to NULL
|
||||
(released; the slot becomes available for visible channels)
|
||||
* Override-pinned (any visibility): untouched; the override's
|
||||
channel_number is treated as a global reservation that other channels
|
||||
skip when packing
|
||||
|
||||
Used by sync_auto_channels (full-pack pass), the post_save Channel signal
|
||||
(single-channel unhide), the bulk-edit endpoint (bulk unhide), and a
|
||||
manual per-group re-pack endpoint.
|
||||
|
||||
Trade-off the user opts into: channel numbers may shift when hide / unhide
|
||||
state changes. To pin a number through hide/unhide cycles, set a
|
||||
channel_number override - the override is honored as a reservation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Channel, ChannelOverride, ChannelGroupM3UAccount
|
||||
from apps.m3u.tasks import _next_available_number
|
||||
from core.utils import ensure_custom_properties_dict
|
||||
from core.utils import (
|
||||
acquire_task_lock,
|
||||
natural_sort_key,
|
||||
release_task_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_compact_group(group_relation):
|
||||
"""Return True if the given ChannelGroupM3UAccount is in compact mode."""
|
||||
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||
return bool(cp.get("compact_numbering"))
|
||||
|
||||
|
||||
def get_group_relation_for_channel(channel):
|
||||
"""Resolve the ChannelGroupM3UAccount that owns this auto-created
|
||||
channel. Returns None for manual channels, or when the relation has
|
||||
been deleted.
|
||||
|
||||
With a Channel Group Override active, sync stores the channel under
|
||||
the override target group's id, not the source group's id recorded on
|
||||
the relation. The direct lookup then misses, so fall back to scanning
|
||||
the account's relations for one whose group_override points at the
|
||||
channel's current group. The fallback runs only on a direct miss, so
|
||||
the common no-override path keeps its single SELECT.
|
||||
"""
|
||||
if not channel.auto_created or not channel.auto_created_by_id:
|
||||
return None
|
||||
if not channel.channel_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ChannelGroupM3UAccount.objects.get(
|
||||
m3u_account_id=channel.auto_created_by_id,
|
||||
channel_group_id=channel.channel_group_id,
|
||||
)
|
||||
except ChannelGroupM3UAccount.DoesNotExist:
|
||||
pass
|
||||
|
||||
# group_override may be stored as int or str; compare as strings so
|
||||
# the match is type-agnostic.
|
||||
target = str(channel.channel_group_id)
|
||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id=channel.auto_created_by_id
|
||||
):
|
||||
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||
if str(cp.get("group_override", "")) == target:
|
||||
return rel
|
||||
return None
|
||||
|
||||
|
||||
def build_reserved_set(exclude_channel_ids=None, range_start=None, range_end=None):
|
||||
"""Return the set of channel numbers currently 'claimed' system-wide
|
||||
for the purposes of a compact pack:
|
||||
|
||||
* Every ChannelOverride.channel_number value (overrides reserve
|
||||
their effective number; duplicates across overrides are allowed
|
||||
and collapse to a single reservation via set semantics)
|
||||
* Every Channel.channel_number value EXCEPT for channels in the
|
||||
passed-in exclude set (those are about to be reassigned)
|
||||
|
||||
When the caller knows the target group has a bounded range, pass
|
||||
`range_start` and `range_end` to scope the scan to numbers that
|
||||
could possibly collide. Without scoping, a single signal-driven
|
||||
unhide reads every channel_number in the database; with scoping it
|
||||
reads at most the values within [start, end] which for typical
|
||||
cable-style ranges is hundreds rather than tens of thousands.
|
||||
|
||||
Float vs int normalization is unnecessary because Python treats
|
||||
50 and 50.0 as equal and produces the same hash, so set membership
|
||||
works directly across both types.
|
||||
"""
|
||||
exclude_channel_ids = set(exclude_channel_ids or [])
|
||||
override_qs = ChannelOverride.objects.filter(channel_number__isnull=False)
|
||||
other_qs = Channel.objects.exclude(channel_number__isnull=True).exclude(
|
||||
id__in=exclude_channel_ids
|
||||
)
|
||||
if range_start is not None:
|
||||
override_qs = override_qs.filter(channel_number__gte=range_start)
|
||||
other_qs = other_qs.filter(channel_number__gte=range_start)
|
||||
if range_end is not None:
|
||||
override_qs = override_qs.filter(channel_number__lte=range_end)
|
||||
other_qs = other_qs.filter(channel_number__lte=range_end)
|
||||
reserved = set(override_qs.values_list("channel_number", flat=True))
|
||||
reserved.update(other_qs.values_list("channel_number", flat=True))
|
||||
reserved.discard(None)
|
||||
return reserved
|
||||
|
||||
|
||||
def _channel_has_number_override(channel):
|
||||
try:
|
||||
ov = channel.override
|
||||
except ChannelOverride.DoesNotExist:
|
||||
return False
|
||||
return ov is not None and ov.channel_number is not None
|
||||
|
||||
|
||||
def assign_compact_numbers_for_channels(channel_ids):
|
||||
"""For each channel ID in the input that became eligible for a number
|
||||
(visible, auto-created, no number override, in a compact-mode group),
|
||||
assign the next available channel number in the group's [start, end]
|
||||
range. Channels whose group is not in compact mode are skipped silently
|
||||
so callers can pass mixed batches without filtering up front.
|
||||
|
||||
Used by both the post_save signal (single-channel unhide) and the bulk
|
||||
edit endpoint (bulk unhide). Returns dict {channel_id: number_or_None}.
|
||||
"""
|
||||
if not channel_ids:
|
||||
return {}
|
||||
channels = list(
|
||||
Channel.objects.filter(
|
||||
id__in=channel_ids,
|
||||
auto_created=True,
|
||||
auto_created_by__isnull=False,
|
||||
hidden_from_output=False,
|
||||
channel_number__isnull=True,
|
||||
).select_related("override", "channel_group", "auto_created_by")
|
||||
)
|
||||
if not channels:
|
||||
return {}
|
||||
|
||||
# Group channels by (account_id, group_id) so each unique pair's
|
||||
# ChannelGroupM3UAccount is resolved with a single SELECT rather than
|
||||
# one per channel.
|
||||
by_pair = {}
|
||||
for ch in channels:
|
||||
if _channel_has_number_override(ch):
|
||||
continue
|
||||
key = (ch.auto_created_by_id, ch.channel_group_id)
|
||||
by_pair.setdefault(key, []).append(ch)
|
||||
if not by_pair:
|
||||
return {}
|
||||
|
||||
pair_keys = list(by_pair.keys())
|
||||
relations_by_pair = {}
|
||||
relations_qs = ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id__in={k[0] for k in pair_keys},
|
||||
channel_group_id__in={k[1] for k in pair_keys},
|
||||
)
|
||||
for rel in relations_qs:
|
||||
relations_by_pair[(rel.m3u_account_id, rel.channel_group_id)] = rel
|
||||
|
||||
# Override fallback: pairs the direct lookup missed carry an override-
|
||||
# target channel_group_id. Resolve them with one extra query over the
|
||||
# unresolved accounts (not one per pair), so the common path keeps its
|
||||
# single narrow query.
|
||||
unresolved = [k for k in pair_keys if k not in relations_by_pair]
|
||||
if unresolved:
|
||||
override_relations = {}
|
||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id__in={k[0] for k in unresolved}
|
||||
):
|
||||
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||
target = cp.get("group_override")
|
||||
if not target:
|
||||
continue
|
||||
try:
|
||||
override_relations[(rel.m3u_account_id, int(target))] = rel
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for key in unresolved:
|
||||
rel = override_relations.get(key)
|
||||
if rel is not None:
|
||||
relations_by_pair[key] = rel
|
||||
|
||||
by_relation = {}
|
||||
for key, group_channels in by_pair.items():
|
||||
rel = relations_by_pair.get(key)
|
||||
if rel is None or not is_compact_group(rel):
|
||||
continue
|
||||
by_relation[rel.id] = (rel, group_channels)
|
||||
|
||||
# Group by account so writes share the `refresh_single_m3u_account`
|
||||
# lock used by sync_auto_channels and the manual repack endpoint.
|
||||
# If sync is in flight for an account, defer to it.
|
||||
by_account = {}
|
||||
for rel, group_channels in by_relation.values():
|
||||
by_account.setdefault(rel.m3u_account_id, []).append(
|
||||
(rel, group_channels)
|
||||
)
|
||||
|
||||
results = {}
|
||||
for account_id, account_pairs in by_account.items():
|
||||
if not acquire_task_lock(
|
||||
"refresh_single_m3u_account", account_id
|
||||
):
|
||||
logger.info(
|
||||
"Compact unhide deferred for account %s: refresh in progress; "
|
||||
"next sync will assign numbers.",
|
||||
account_id,
|
||||
)
|
||||
for _, group_channels in account_pairs:
|
||||
for ch in group_channels:
|
||||
results[ch.id] = None
|
||||
continue
|
||||
|
||||
# try/finally release: the Redis lock is not transactional, so a
|
||||
# transaction.on_commit release would leak the lock when an
|
||||
# outer atomic rolls back, blocking subsequent syncs until TTL.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for rel, group_channels in account_pairs:
|
||||
start = int(rel.auto_sync_channel_start or 1)
|
||||
end = (
|
||||
int(rel.auto_sync_channel_end)
|
||||
if rel.auto_sync_channel_end
|
||||
else None
|
||||
)
|
||||
reserved = build_reserved_set(
|
||||
exclude_channel_ids=[c.id for c in group_channels],
|
||||
range_start=start,
|
||||
range_end=end,
|
||||
)
|
||||
to_update = []
|
||||
for ch in group_channels:
|
||||
next_num = _next_available_number(
|
||||
reserved, start, end=end
|
||||
)
|
||||
if next_num is None:
|
||||
results[ch.id] = None
|
||||
continue
|
||||
ch.channel_number = next_num
|
||||
reserved.add(next_num)
|
||||
results[ch.id] = next_num
|
||||
to_update.append(ch)
|
||||
if to_update:
|
||||
Channel.objects.bulk_update(
|
||||
to_update, ["channel_number"], batch_size=100
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
release_task_lock(
|
||||
"refresh_single_m3u_account", account_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to release compact-unhide lock for account "
|
||||
"%s: %s",
|
||||
account_id,
|
||||
e,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def repack_group(group_relation):
|
||||
"""Renumber every auto-created channel in the given group+account.
|
||||
|
||||
Visible non-override channels are assigned sequentially in
|
||||
[start, end] using the group's configured channel_sort_order.
|
||||
Hidden non-override channels have their channel_number set to
|
||||
None (slot released). Override-pinned channels are untouched.
|
||||
|
||||
Returns a dict with assigned/released/failed counts. ``failed``
|
||||
counts visible channels that could not fit because the range was
|
||||
exhausted; their channel_number is set to None so the state is
|
||||
unambiguous instead of stuck at a stale number.
|
||||
|
||||
All writes run inside a single transaction so concurrent readers
|
||||
(HDHR/M3U/EPG output paths) never observe a half-packed state.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
return _repack_inner(group_relation)
|
||||
|
||||
|
||||
def _repack_inner(group_relation):
|
||||
account_id = group_relation.m3u_account_id
|
||||
group_id = group_relation.channel_group_id
|
||||
|
||||
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||
sort_order = cp.get("channel_sort_order") or ""
|
||||
sort_reverse = bool(cp.get("channel_sort_reverse"))
|
||||
|
||||
start = int(group_relation.auto_sync_channel_start or 1)
|
||||
end = (
|
||||
int(group_relation.auto_sync_channel_end)
|
||||
if group_relation.auto_sync_channel_end
|
||||
else None
|
||||
)
|
||||
|
||||
# Match the override target group too: channels created under an
|
||||
# override live under the target's id, not the source group's.
|
||||
group_ids = {group_id}
|
||||
override_group_id = cp.get("group_override")
|
||||
if override_group_id:
|
||||
try:
|
||||
group_ids.add(int(override_group_id))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring non-numeric group_override %r on relation %s",
|
||||
override_group_id,
|
||||
group_relation.id,
|
||||
)
|
||||
|
||||
# Known limitation: if two source groups on the same account override
|
||||
# into the SAME target group, their channels are indistinguishable
|
||||
# here (channels carry no source-group back-reference), so each repack
|
||||
# renumbers the shared target's channels into its own range.
|
||||
# order_by("id") makes the pack deterministic. Without it the query
|
||||
# returns rows in unspecified physical order, which shifts after the
|
||||
# renumber's own UPDATEs and autovacuum, so the default "provider" sort
|
||||
# below would repack channels into different numbers on every sync.
|
||||
# id order is creation order, which tracks the provider stream order.
|
||||
channels = list(
|
||||
Channel.objects.filter(
|
||||
auto_created=True,
|
||||
auto_created_by_id=account_id,
|
||||
channel_group_id__in=group_ids,
|
||||
).select_related("override").order_by("id")
|
||||
)
|
||||
|
||||
visible = []
|
||||
hidden = []
|
||||
pinned = []
|
||||
for ch in channels:
|
||||
if _channel_has_number_override(ch):
|
||||
pinned.append(ch)
|
||||
elif ch.hidden_from_output:
|
||||
hidden.append(ch)
|
||||
else:
|
||||
visible.append(ch)
|
||||
|
||||
# Sort the visible set by the group's configured channel_sort_order.
|
||||
# Provider order (the default) keeps the id order from the query above.
|
||||
# Each explicit sort carries c.id as a secondary key so equal values
|
||||
# (e.g. blank tvg_id) break ties deterministically instead of churning.
|
||||
if sort_order == "name":
|
||||
visible.sort(
|
||||
key=lambda c: (natural_sort_key(c.name or ""), c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sort_order == "tvg_id":
|
||||
visible.sort(
|
||||
key=lambda c: (c.tvg_id or "", c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sort_order == "updated_at":
|
||||
visible.sort(
|
||||
key=lambda c: (c.updated_at, c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
|
||||
# Exclude every channel in this group: the pinned channel's raw value
|
||||
# is irrelevant (the override is reserved globally and cleared below).
|
||||
# Scope to the group's range when bounded to keep the set small.
|
||||
affected_ids = [c.id for c in (visible + hidden + pinned)]
|
||||
reserved = build_reserved_set(
|
||||
exclude_channel_ids=affected_ids,
|
||||
range_start=start,
|
||||
range_end=end,
|
||||
)
|
||||
|
||||
assigned_count = 0
|
||||
failed_count = 0
|
||||
visible_to_update = []
|
||||
for ch in visible:
|
||||
next_num = _next_available_number(reserved, start, end=end)
|
||||
if next_num is None:
|
||||
failed_count += 1
|
||||
if ch.channel_number is not None:
|
||||
ch.channel_number = None
|
||||
visible_to_update.append(ch)
|
||||
continue
|
||||
if ch.channel_number != next_num:
|
||||
ch.channel_number = next_num
|
||||
visible_to_update.append(ch)
|
||||
reserved.add(next_num)
|
||||
assigned_count += 1
|
||||
|
||||
if visible_to_update:
|
||||
Channel.objects.bulk_update(
|
||||
visible_to_update, ["channel_number"], batch_size=100
|
||||
)
|
||||
|
||||
released_count = 0
|
||||
hidden_with_num = [c for c in hidden if c.channel_number is not None]
|
||||
if hidden_with_num:
|
||||
for c in hidden_with_num:
|
||||
c.channel_number = None
|
||||
Channel.objects.bulk_update(
|
||||
hidden_with_num, ["channel_number"], batch_size=100
|
||||
)
|
||||
released_count = len(hidden_with_num)
|
||||
|
||||
# Pinned channels: clear raw channel_number. The override controls
|
||||
# their effective number; leaving a stale raw value would pollute
|
||||
# uniqueness checks and could resurrect on override clear.
|
||||
pinned_with_num = [c for c in pinned if c.channel_number is not None]
|
||||
if pinned_with_num:
|
||||
for c in pinned_with_num:
|
||||
c.channel_number = None
|
||||
Channel.objects.bulk_update(
|
||||
pinned_with_num, ["channel_number"], batch_size=100
|
||||
)
|
||||
|
||||
return {
|
||||
"assigned": assigned_count,
|
||||
"released": released_count,
|
||||
"failed": failed_count,
|
||||
}
|
||||
927
apps/channels/epg_matching.py
Normal file
927
apps/channels/epg_matching.py
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
"""
|
||||
EPG channel matching: fuzzy scoring, optional ML validation, and UI notifications.
|
||||
|
||||
Celery tasks in tasks.py call into this module; keep orchestration here and
|
||||
task wiring thin so matching logic stays testable without a worker.
|
||||
"""
|
||||
import gc
|
||||
import heapq
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
from apps.epg.models import EPGData
|
||||
from core.models import CoreSettings
|
||||
from core.utils import send_websocket_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ml_model_cache = {'sentence_transformer': None}
|
||||
_normalize_settings_cache = None
|
||||
|
||||
ML_CANDIDATE_LIMIT = 20
|
||||
SINGLE_CHANNEL_MATCH_TIMEOUT_MS = 180_000
|
||||
|
||||
COMMON_EXTRANEOUS_WORDS = [
|
||||
"tv", "channel", "network", "television",
|
||||
"east", "west", "hd", "uhd", "24/7",
|
||||
"1080p", "720p", "540p", "480p",
|
||||
"film", "movie", "movies",
|
||||
]
|
||||
|
||||
|
||||
def release_ml_models():
|
||||
"""Unload sentence transformer and encourage PyTorch to release memory."""
|
||||
if _ml_model_cache['sentence_transformer'] is None:
|
||||
return
|
||||
logger.info("Cleaning up ML models from memory")
|
||||
model = _ml_model_cache['sentence_transformer']
|
||||
_ml_model_cache['sentence_transformer'] = None
|
||||
del model
|
||||
try:
|
||||
import torch
|
||||
if hasattr(torch, 'cuda') and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
gc.collect()
|
||||
|
||||
|
||||
def clear_normalize_settings_cache():
|
||||
"""Reset cached normalization settings after a matching run."""
|
||||
global _normalize_settings_cache
|
||||
_normalize_settings_cache = None
|
||||
|
||||
|
||||
def cleanup_after_matching():
|
||||
"""Release ML models and normalization cache after a matching run."""
|
||||
release_ml_models()
|
||||
clear_normalize_settings_cache()
|
||||
|
||||
|
||||
def get_sentence_transformer():
|
||||
"""Lazy load the sentence transformer model only when needed."""
|
||||
if _ml_model_cache['sentence_transformer'] is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from sentence_transformers import util
|
||||
|
||||
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||
cache_dir = "/data/models"
|
||||
disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true'
|
||||
|
||||
if disable_downloads:
|
||||
hf_model_path = os.path.join(cache_dir, f"models--{model_name.replace('/', '--')}")
|
||||
if not os.path.exists(hf_model_path):
|
||||
logger.warning(
|
||||
"ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). "
|
||||
"Skipping ML matching."
|
||||
)
|
||||
return None, None
|
||||
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
logger.info(f"Loading sentence transformer model (cache: {cache_dir})")
|
||||
_ml_model_cache['sentence_transformer'] = SentenceTransformer(
|
||||
model_name,
|
||||
cache_folder=cache_dir,
|
||||
)
|
||||
return _ml_model_cache['sentence_transformer'], util
|
||||
except ImportError:
|
||||
logger.warning("sentence-transformers not available - ML-enhanced matching disabled")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load sentence transformer: {e}")
|
||||
return None, None
|
||||
|
||||
from sentence_transformers import util
|
||||
return _ml_model_cache['sentence_transformer'], util
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Normalize a channel/EPG name for fuzzy matching."""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
global _normalize_settings_cache
|
||||
if _normalize_settings_cache is None:
|
||||
prefixes = []
|
||||
suffixes = []
|
||||
custom_strings = []
|
||||
try:
|
||||
settings = CoreSettings.get_epg_settings()
|
||||
mode = settings.get("epg_match_mode", "default")
|
||||
if mode == "advanced":
|
||||
prefixes = settings.get("epg_match_ignore_prefixes", [])
|
||||
suffixes = settings.get("epg_match_ignore_suffixes", [])
|
||||
custom_strings = settings.get("epg_match_ignore_custom", [])
|
||||
if not isinstance(prefixes, list):
|
||||
prefixes = []
|
||||
if not isinstance(suffixes, list):
|
||||
suffixes = []
|
||||
if not isinstance(custom_strings, list):
|
||||
custom_strings = []
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load EPG matching settings: {e}")
|
||||
_normalize_settings_cache = (prefixes, suffixes, custom_strings)
|
||||
|
||||
prefixes, suffixes, custom_strings = _normalize_settings_cache
|
||||
result = name
|
||||
|
||||
for prefix in prefixes:
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
continue
|
||||
if result.startswith(prefix):
|
||||
result = result[len(prefix):]
|
||||
break
|
||||
|
||||
for suffix in suffixes:
|
||||
if not suffix or not isinstance(suffix, str):
|
||||
continue
|
||||
if result.endswith(suffix):
|
||||
result = result[:-len(suffix)]
|
||||
break
|
||||
|
||||
for custom in custom_strings:
|
||||
if not custom or not isinstance(custom, str):
|
||||
continue
|
||||
try:
|
||||
result = result.replace(custom, "")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to remove custom string '{custom}': {e}")
|
||||
|
||||
norm = result.lower()
|
||||
norm = re.sub(r"\[.*?\]", "", norm)
|
||||
|
||||
call_sign_match = re.search(r"\(([A-Z]{3,5})\)", name)
|
||||
preserved_call_sign = ""
|
||||
if call_sign_match:
|
||||
preserved_call_sign = " " + call_sign_match.group(1).lower()
|
||||
|
||||
norm = re.sub(r"\(.*?\)", "", norm)
|
||||
norm = norm + preserved_call_sign
|
||||
norm = re.sub(r"[^\w\s]", "", norm)
|
||||
tokens = [t for t in norm.split() if t not in COMMON_EXTRANEOUS_WORDS]
|
||||
return " ".join(tokens).strip()
|
||||
|
||||
|
||||
def send_epg_matching_progress(total_channels, matched_channels, current_channel_name="", stage="matching"):
|
||||
"""Send bulk EPG matching progress via WebSocket."""
|
||||
matched_count = (
|
||||
len(matched_channels) if isinstance(matched_channels, list) else matched_channels
|
||||
)
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'type': 'epg_matching_progress',
|
||||
'total': total_channels,
|
||||
'matched': matched_count,
|
||||
'remaining': total_channels - matched_count,
|
||||
'current_channel': current_channel_name,
|
||||
'stage': stage,
|
||||
'progress_percent': round(matched_count / total_channels * 100, 1) if total_channels > 0 else 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_single_channel_epg_match_result(channel_id, matched, message, channel=None, epg_data=None):
|
||||
"""Notify the UI that a single-channel EPG match attempt has finished."""
|
||||
try:
|
||||
from apps.channels.serializers import ChannelSerializer
|
||||
|
||||
payload = {
|
||||
"type": "single_channel_epg_match",
|
||||
"channel_id": channel_id,
|
||||
"matched": matched,
|
||||
"message": message,
|
||||
}
|
||||
if channel is not None:
|
||||
payload["channel"] = ChannelSerializer(channel).data
|
||||
if epg_data is not None:
|
||||
payload["epg_id"] = epg_data.id
|
||||
payload["epg_name"] = epg_data.name
|
||||
|
||||
send_websocket_update('updates', 'update', payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send single channel EPG match result: {e}")
|
||||
|
||||
|
||||
def _compute_fuzzy_score(chan_norm, row, region_code=None):
|
||||
"""Compute fuzzy match score with optional region bonus/penalty."""
|
||||
if not row.get("norm_name"):
|
||||
return 0
|
||||
base_score = fuzz.ratio(chan_norm, row["norm_name"])
|
||||
bonus = 0
|
||||
if region_code and row.get("tvg_id"):
|
||||
combined_text = row["tvg_id"].lower() + " " + row["name"].lower()
|
||||
dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
|
||||
if dot_regions:
|
||||
bonus = 15 if region_code in dot_regions else -15
|
||||
elif region_code in combined_text:
|
||||
bonus = 10
|
||||
return base_score + bonus
|
||||
|
||||
|
||||
def _ml_cosine_similarities(st_model, util, query_text, candidate_texts):
|
||||
"""Encode only the query plus candidate texts (not the full EPG database)."""
|
||||
if not candidate_texts:
|
||||
return []
|
||||
texts = [query_text] + list(candidate_texts)
|
||||
embeddings = st_model.encode(texts, convert_to_tensor=True, show_progress_bar=False)
|
||||
sim_scores = util.cos_sim(embeddings[0:1], embeddings[1:])[0]
|
||||
return [float(s) for s in sim_scores]
|
||||
|
||||
|
||||
def _active_epg_lookup_queryset():
|
||||
"""Lightweight queryset for exact EPG lookups (includes nameless entries)."""
|
||||
return (
|
||||
EPGData.objects
|
||||
.filter(epg_source__is_active=True)
|
||||
.values('id', 'tvg_id', 'name', 'epg_source_id', 'epg_source__priority')
|
||||
)
|
||||
|
||||
|
||||
def _active_epg_fuzzy_queryset():
|
||||
"""Lightweight queryset for fuzzy EPG matching (requires a display name)."""
|
||||
return (
|
||||
_active_epg_lookup_queryset()
|
||||
.filter(name__isnull=False)
|
||||
.exclude(name='')
|
||||
)
|
||||
|
||||
|
||||
def _row_from_epg_values(values_row):
|
||||
tvg_id = values_row.get('tvg_id') or ''
|
||||
normalized_tvg_id = tvg_id.strip().lower() if tvg_id else ''
|
||||
return {
|
||||
'id': values_row['id'],
|
||||
'tvg_id': normalized_tvg_id,
|
||||
'original_tvg_id': tvg_id,
|
||||
'name': values_row['name'],
|
||||
'epg_source_id': values_row['epg_source_id'],
|
||||
'epg_source_priority': values_row.get('epg_source__priority') or 0,
|
||||
}
|
||||
|
||||
|
||||
def lookup_epg_by_tvg_id(tvg_id):
|
||||
"""Exact tvg_id lookup without loading the full EPG catalog into memory."""
|
||||
if not tvg_id:
|
||||
return None
|
||||
values_row = _active_epg_lookup_queryset().filter(tvg_id__iexact=tvg_id.strip()).first()
|
||||
return _row_from_epg_values(values_row) if values_row else None
|
||||
|
||||
|
||||
def build_epg_matching_catalog():
|
||||
"""
|
||||
Build the in-memory EPG catalog for bulk matching using a streaming DB cursor.
|
||||
|
||||
Returns (epg_data, tvg_id_index): the full catalog plus an O(1) in-memory
|
||||
tvg_id lookup table (no extra DB queries). The index prefers the first entry
|
||||
per tvg_id after priority sorting.
|
||||
"""
|
||||
epg_data = []
|
||||
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||
row = _row_from_epg_values(values_row)
|
||||
row['norm_name'] = normalize_name(row['name'])
|
||||
epg_data.append(row)
|
||||
epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True)
|
||||
return epg_data, build_epg_tvg_id_index(epg_data)
|
||||
|
||||
|
||||
def build_epg_tvg_id_index(epg_data):
|
||||
"""
|
||||
Build an in-memory tvg_id -> row index from an EPG catalog (no DB queries).
|
||||
epg_data must be sorted by source priority (highest first) so the first
|
||||
entry wins when multiple sources share the same tvg_id.
|
||||
"""
|
||||
index = {}
|
||||
for row in epg_data:
|
||||
tvg_id = row.get("tvg_id")
|
||||
if tvg_id and tvg_id not in index:
|
||||
index[tvg_id] = row
|
||||
return index
|
||||
|
||||
|
||||
def _dispatch_program_parse_for_epg_assignments(changed_associations):
|
||||
"""
|
||||
Queue guide/program refresh for newly assigned EPG ids.
|
||||
|
||||
bulk_update bypasses post_save, so callers must invoke this when epg_data
|
||||
actually changes (mirrors the M3U sync path).
|
||||
"""
|
||||
if not changed_associations:
|
||||
return 0
|
||||
|
||||
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
|
||||
|
||||
epg_ids = {
|
||||
assoc["epg_data_id"]
|
||||
for assoc in changed_associations
|
||||
if assoc.get("epg_data_id")
|
||||
}
|
||||
return dispatch_program_refresh_for_epg_ids(epg_ids)
|
||||
|
||||
|
||||
def _log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method):
|
||||
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||
logger.debug(
|
||||
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||
f"unchanged - already on EPG '{epg_name or '?'}' "
|
||||
f"(id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||
)
|
||||
|
||||
|
||||
def _record_epg_match(
|
||||
chan,
|
||||
epg_id,
|
||||
*,
|
||||
epg_name,
|
||||
epg_tvg_id,
|
||||
match_method,
|
||||
channels_to_update,
|
||||
matched_channels,
|
||||
unchanged_channels,
|
||||
):
|
||||
"""Record a match result; skip channels_to_update when assignment is already correct."""
|
||||
if chan.get("current_epg_data_id") == epg_id:
|
||||
unchanged_channels.append((chan["id"], chan.get("name") or "", epg_tvg_id or ""))
|
||||
_log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method)
|
||||
return
|
||||
|
||||
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||
fallback_name = chan.get("fallback_name") or chan_name
|
||||
chan["epg_data_id"] = epg_id
|
||||
channels_to_update.append(chan)
|
||||
matched_channels.append((chan["id"], fallback_name, epg_tvg_id or ""))
|
||||
logger.info(
|
||||
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||
f"=> EPG '{epg_name or '?'}' (id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||
)
|
||||
|
||||
|
||||
def apply_matched_epg_to_channels(channels_to_update_dicts):
|
||||
"""
|
||||
Assign matched EPG rows to channels using two DB queries (channels + EPG).
|
||||
|
||||
Skips channels that already have the matched EPG. Returns association dicts
|
||||
for channels whose epg_data assignment actually changed, and dispatches
|
||||
program-parse tasks only for those new assignments.
|
||||
"""
|
||||
from apps.channels.models import Channel
|
||||
|
||||
if not channels_to_update_dicts:
|
||||
return []
|
||||
|
||||
channel_ids = [d["id"] for d in channels_to_update_dicts]
|
||||
epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts}
|
||||
epg_ids = {epg_id for epg_id in epg_mapping.values() if epg_id}
|
||||
|
||||
epg_by_id = {epg.id: epg for epg in EPGData.objects.filter(id__in=epg_ids)}
|
||||
channels_list = list(Channel.objects.filter(id__in=channel_ids))
|
||||
|
||||
changed_associations = []
|
||||
channels_to_bulk = []
|
||||
for channel_obj in channels_list:
|
||||
epg_data_id = epg_mapping.get(channel_obj.id)
|
||||
if not epg_data_id:
|
||||
continue
|
||||
if channel_obj.epg_data_id == epg_data_id:
|
||||
epg_row = epg_by_id.get(epg_data_id)
|
||||
_log_unchanged_epg_assignment(
|
||||
{
|
||||
"id": channel_obj.id,
|
||||
"name": channel_obj.name,
|
||||
"original_tvg_id": channel_obj.tvg_id,
|
||||
},
|
||||
epg_data_id,
|
||||
epg_row.name if epg_row else None,
|
||||
epg_row.tvg_id if epg_row else None,
|
||||
"apply",
|
||||
)
|
||||
continue
|
||||
epg_data_obj = epg_by_id.get(epg_data_id)
|
||||
if epg_data_obj:
|
||||
channel_obj.epg_data = epg_data_obj
|
||||
channels_to_bulk.append(channel_obj)
|
||||
changed_associations.append(
|
||||
{"channel_id": channel_obj.id, "epg_data_id": epg_data_id}
|
||||
)
|
||||
else:
|
||||
logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}")
|
||||
|
||||
if channels_to_bulk:
|
||||
Channel.objects.bulk_update(channels_to_bulk, ["epg_data"])
|
||||
|
||||
parse_dispatched = _dispatch_program_parse_for_epg_assignments(changed_associations)
|
||||
if parse_dispatched:
|
||||
logger.info(
|
||||
f"Dispatched {parse_dispatched} EPG program parse task(s) for changed assignments"
|
||||
)
|
||||
|
||||
return changed_associations
|
||||
|
||||
|
||||
def get_preferred_region_code():
|
||||
try:
|
||||
region_obj = CoreSettings.objects.get(key="preferred-region")
|
||||
return region_obj.value.strip().lower()
|
||||
except CoreSettings.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def _fuzzy_scan_core(chan_norm, rows, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""
|
||||
Single-pass fuzzy scan: track best match and top-K candidates.
|
||||
Rows must already include norm_name when scanning an in-memory catalog.
|
||||
"""
|
||||
best_score = 0
|
||||
best_epg = None
|
||||
top_heap = []
|
||||
seq = 0
|
||||
scanned = 0
|
||||
|
||||
for row in rows:
|
||||
if not row.get("norm_name"):
|
||||
continue
|
||||
|
||||
scanned += 1
|
||||
score = _compute_fuzzy_score(chan_norm, row, region_code)
|
||||
if score <= 0:
|
||||
continue
|
||||
|
||||
if score > 50:
|
||||
logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score}")
|
||||
|
||||
priority = row['epg_source_priority']
|
||||
if score > best_score or (
|
||||
score == best_score
|
||||
and priority > (best_epg.get('epg_source_priority', 0) if best_epg else -1)
|
||||
):
|
||||
best_score = score
|
||||
best_epg = row
|
||||
|
||||
seq += 1
|
||||
if len(top_heap) < candidate_limit:
|
||||
heapq.heappush(top_heap, (score, priority, seq, row))
|
||||
else:
|
||||
smallest_score, smallest_priority, _, _ = top_heap[0]
|
||||
if score > smallest_score or (score == smallest_score and priority > smallest_priority):
|
||||
heapq.heapreplace(top_heap, (score, priority, seq, row))
|
||||
|
||||
top_candidates = sorted(top_heap, key=lambda item: (item[0], item[1]), reverse=True)
|
||||
return best_score, best_epg, [(score, row) for score, _, _, row in top_candidates], scanned
|
||||
|
||||
|
||||
def fuzzy_scan_epg_list(chan_norm, epg_data, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""Fuzzy scan over a pre-built in-memory EPG catalog (bulk matching)."""
|
||||
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||
return _fuzzy_scan_core(chan_norm, epg_data, region_code, candidate_limit)
|
||||
|
||||
|
||||
def stream_fuzzy_epg_scan(chan_norm, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""Stream fuzzy scan over active EPG entries (single-channel matching)."""
|
||||
|
||||
def row_iterator():
|
||||
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||
row = _row_from_epg_values(values_row)
|
||||
row['norm_name'] = normalize_name(row['name'])
|
||||
yield row
|
||||
|
||||
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||
return _fuzzy_scan_core(chan_norm, row_iterator(), region_code, candidate_limit)
|
||||
|
||||
|
||||
def _get_epg_match_thresholds(is_bulk_matching):
|
||||
if is_bulk_matching:
|
||||
return {
|
||||
'FUZZY_HIGH_CONFIDENCE': 90,
|
||||
'FUZZY_SKIP_ML': 80,
|
||||
'FUZZY_MEDIUM_CONFIDENCE': 70,
|
||||
'ML_HIGH_CONFIDENCE': 0.75,
|
||||
'ML_LAST_RESORT': 0.65,
|
||||
'FUZZY_LAST_RESORT_MIN': 50,
|
||||
}
|
||||
return {
|
||||
'FUZZY_HIGH_CONFIDENCE': 85,
|
||||
'FUZZY_SKIP_ML': 75,
|
||||
'FUZZY_MEDIUM_CONFIDENCE': 40,
|
||||
'ML_HIGH_CONFIDENCE': 0.65,
|
||||
'ML_LAST_RESORT': 0.50,
|
||||
'FUZZY_LAST_RESORT_MIN': 20,
|
||||
}
|
||||
|
||||
|
||||
def try_epg_name_match(chan, best_score, best_epg, top_candidates, is_bulk_matching,
|
||||
use_ml=True, ml_state=None):
|
||||
"""
|
||||
Apply fuzzy/ML thresholds to a channel's best fuzzy result.
|
||||
Returns the matched EPG row dict, or None.
|
||||
"""
|
||||
if not best_epg:
|
||||
return None
|
||||
|
||||
thresholds = _get_epg_match_thresholds(is_bulk_matching)
|
||||
fuzzy_high = thresholds['FUZZY_HIGH_CONFIDENCE']
|
||||
fuzzy_skip_ml = thresholds['FUZZY_SKIP_ML']
|
||||
fuzzy_medium = thresholds['FUZZY_MEDIUM_CONFIDENCE']
|
||||
ml_high = thresholds['ML_HIGH_CONFIDENCE']
|
||||
ml_last_resort = thresholds['ML_LAST_RESORT']
|
||||
fuzzy_last_resort_min = thresholds['FUZZY_LAST_RESORT_MIN']
|
||||
|
||||
if best_score >= fuzzy_high:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||
f"(score={best_score})"
|
||||
)
|
||||
return best_epg
|
||||
|
||||
if best_score >= fuzzy_skip_ml:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||
f"(fuzzy={best_score}, ML skipped)"
|
||||
)
|
||||
return best_epg
|
||||
|
||||
if ml_state is None:
|
||||
ml_state = {}
|
||||
|
||||
st_model = ml_state.get('st_model')
|
||||
util = ml_state.get('util')
|
||||
|
||||
if best_score >= fuzzy_medium and use_ml:
|
||||
if st_model is None:
|
||||
st_model, util = get_sentence_transformer()
|
||||
ml_state['st_model'] = st_model
|
||||
ml_state['util'] = util
|
||||
|
||||
if st_model:
|
||||
try:
|
||||
logger.info("Validating fuzzy best match with ML model (single candidate)")
|
||||
sims = _ml_cosine_similarities(st_model, util, chan["norm_chan"], [best_epg["norm_name"]])
|
||||
top_value = sims[0] if sims else 0.0
|
||||
|
||||
if top_value >= ml_high - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={best_epg['tvg_id']} "
|
||||
f"(fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return best_epg
|
||||
if top_value >= ml_last_resort - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG "
|
||||
f"tvg_id={best_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return best_epg
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, "
|
||||
f"ML-sim={top_value:.2f} < {ml_last_resort}, skipping"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ML matching failed for channel {chan['id']}: {e}")
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping"
|
||||
)
|
||||
return None
|
||||
|
||||
if best_score >= fuzzy_last_resort_min and use_ml:
|
||||
if st_model is None:
|
||||
st_model, util = get_sentence_transformer()
|
||||
ml_state['st_model'] = st_model
|
||||
ml_state['util'] = util
|
||||
|
||||
if st_model and top_candidates:
|
||||
try:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => trying ML last resort against "
|
||||
f"top {len(top_candidates)} fuzzy candidates (fuzzy={best_score})"
|
||||
)
|
||||
candidate_rows = [row for _, row in top_candidates]
|
||||
sims = _ml_cosine_similarities(
|
||||
st_model,
|
||||
util,
|
||||
chan["norm_chan"],
|
||||
[row["norm_name"] for row in candidate_rows],
|
||||
)
|
||||
top_index = max(range(len(sims)), key=lambda i: sims[i])
|
||||
top_value = sims[top_index]
|
||||
matched_epg = candidate_rows[top_index]
|
||||
|
||||
if top_value >= ml_last_resort - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match "
|
||||
f"EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return matched_epg
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => desperate last resort "
|
||||
f"ML-sim {top_value:.2f} < {ml_last_resort}, giving up"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}")
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||
f"< {fuzzy_medium}, giving up"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||
f"< {fuzzy_medium}, no ML fallback available"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def prepare_channel_match_data(channel):
|
||||
"""Build the channel dict used by matching logic."""
|
||||
normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else ""
|
||||
normalized_gracenote_id = (
|
||||
channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else ""
|
||||
)
|
||||
return {
|
||||
"id": channel.id,
|
||||
"name": channel.name,
|
||||
"tvg_id": normalized_tvg_id,
|
||||
"original_tvg_id": channel.tvg_id,
|
||||
"gracenote_id": normalized_gracenote_id,
|
||||
"original_gracenote_id": channel.tvc_guide_stationid,
|
||||
"fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name,
|
||||
"norm_chan": normalize_name(channel.name),
|
||||
"current_epg_data_id": channel.epg_data_id,
|
||||
}
|
||||
|
||||
|
||||
def match_channels_to_epg(
|
||||
channels_data,
|
||||
epg_data,
|
||||
region_code=None,
|
||||
use_ml=True,
|
||||
send_progress=True,
|
||||
epg_tvg_id_index=None,
|
||||
):
|
||||
"""
|
||||
Match channels to EPG rows using exact ID, fuzzy, and optional ML strategies.
|
||||
|
||||
epg_tvg_id_index: optional pre-built tvg_id -> row map from build_epg_matching_catalog().
|
||||
"""
|
||||
channels_to_update = []
|
||||
matched_channels = []
|
||||
unchanged_channels = []
|
||||
total_channels = len(channels_data)
|
||||
|
||||
if send_progress:
|
||||
send_epg_matching_progress(total_channels, 0, stage="starting")
|
||||
|
||||
is_bulk_matching = len(channels_data) > 1
|
||||
ml_state = {}
|
||||
epg_by_tvg_id = epg_tvg_id_index if epg_tvg_id_index is not None else build_epg_tvg_id_index(epg_data)
|
||||
|
||||
if is_bulk_matching:
|
||||
logger.info(f"Using conservative thresholds for bulk matching ({total_channels} channels)")
|
||||
else:
|
||||
logger.info("Using aggressive thresholds for single channel matching")
|
||||
|
||||
for index, chan in enumerate(channels_data):
|
||||
normalized_tvg_id = chan.get("tvg_id", "")
|
||||
fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"]
|
||||
|
||||
resolved_count = len(matched_channels) + len(unchanged_channels)
|
||||
if send_progress and (index < 5 or index % 5 == 0 or index == total_channels - 1):
|
||||
send_epg_matching_progress(
|
||||
total_channels,
|
||||
resolved_count,
|
||||
current_channel_name=chan["name"][:50],
|
||||
stage="matching",
|
||||
)
|
||||
|
||||
if normalized_tvg_id:
|
||||
epg_row = epg_by_tvg_id.get(normalized_tvg_id)
|
||||
if epg_row:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
epg_row["id"],
|
||||
epg_name=epg_row.get("name"),
|
||||
epg_tvg_id=epg_row.get("original_tvg_id") or epg_row.get("tvg_id"),
|
||||
match_method="exact tvg_id",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_gracenote_id = chan.get("gracenote_id", "")
|
||||
if normalized_gracenote_id:
|
||||
epg_by_gracenote_id = epg_by_tvg_id.get(normalized_gracenote_id)
|
||||
if epg_by_gracenote_id:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
epg_by_gracenote_id["id"],
|
||||
epg_name=epg_by_gracenote_id.get("name"),
|
||||
epg_tvg_id=epg_by_gracenote_id.get("original_tvg_id")
|
||||
or epg_by_gracenote_id.get("tvg_id"),
|
||||
match_method="exact gracenote_id",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
continue
|
||||
|
||||
if not chan["norm_chan"]:
|
||||
logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping")
|
||||
continue
|
||||
|
||||
best_score, best_epg, top_candidates, _scanned = fuzzy_scan_epg_list(
|
||||
chan["norm_chan"], epg_data, region_code
|
||||
)
|
||||
if not best_epg:
|
||||
logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found")
|
||||
continue
|
||||
|
||||
matched_epg = try_epg_name_match(
|
||||
chan,
|
||||
best_score,
|
||||
best_epg,
|
||||
top_candidates,
|
||||
is_bulk_matching,
|
||||
use_ml=use_ml,
|
||||
ml_state=ml_state,
|
||||
)
|
||||
if matched_epg:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
matched_epg["id"],
|
||||
epg_name=matched_epg.get("name"),
|
||||
epg_tvg_id=matched_epg.get("original_tvg_id") or matched_epg.get("tvg_id"),
|
||||
match_method=f"fuzzy (score={best_score})",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
|
||||
if send_progress:
|
||||
send_epg_matching_progress(
|
||||
total_channels,
|
||||
len(matched_channels) + len(unchanged_channels),
|
||||
stage="completed",
|
||||
)
|
||||
|
||||
return {
|
||||
"channels_to_update": channels_to_update,
|
||||
"matched_channels": matched_channels,
|
||||
"unchanged_channels": unchanged_channels,
|
||||
}
|
||||
|
||||
|
||||
def run_single_channel_epg_match(channel_id):
|
||||
"""
|
||||
Match one channel to EPG data. Always notifies the UI via WebSocket before returning.
|
||||
"""
|
||||
from apps.channels.models import Channel
|
||||
|
||||
channel = None
|
||||
try:
|
||||
logger.info(f"Starting integrated single channel EPG matching for channel ID {channel_id}")
|
||||
|
||||
try:
|
||||
channel = Channel.objects.get(id=channel_id)
|
||||
except Channel.DoesNotExist:
|
||||
message = "Channel not found"
|
||||
send_single_channel_epg_match_result(channel_id, False, message)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
channel_data = prepare_channel_match_data(channel)
|
||||
logger.info(
|
||||
f"Channel data prepared: name='{channel.name}', tvg_id='{channel_data['tvg_id']}', "
|
||||
f"gracenote_id='{channel_data['gracenote_id']}', norm_chan='{channel_data['norm_chan']}'"
|
||||
)
|
||||
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching")
|
||||
region_code = get_preferred_region_code()
|
||||
|
||||
fallback_name = channel_data["tvg_id"] if channel_data["tvg_id"] else channel.name
|
||||
matched_epg_row = None
|
||||
match_via = None
|
||||
|
||||
if channel_data["tvg_id"]:
|
||||
matched_epg_row = lookup_epg_by_tvg_id(channel_data["tvg_id"])
|
||||
if matched_epg_row:
|
||||
match_via = matched_epg_row["tvg_id"]
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{fallback_name}' => EPG found by exact tvg_id={match_via}"
|
||||
)
|
||||
|
||||
if not matched_epg_row and channel_data["gracenote_id"]:
|
||||
matched_epg_row = lookup_epg_by_tvg_id(channel_data["gracenote_id"])
|
||||
if matched_epg_row:
|
||||
match_via = f"gracenote:{matched_epg_row['tvg_id']}"
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{fallback_name}' => EPG found by exact "
|
||||
f"gracenote_id={channel_data['gracenote_id']}"
|
||||
)
|
||||
|
||||
if not matched_epg_row and channel_data["norm_chan"]:
|
||||
best_score, best_epg, top_candidates, scanned = stream_fuzzy_epg_scan(
|
||||
channel_data["norm_chan"], region_code
|
||||
)
|
||||
logger.info(
|
||||
f"Matching single channel '{channel.name}' against {scanned} EPG entries"
|
||||
)
|
||||
if best_epg:
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{channel.name}' => best match: '{best_epg['name']}' "
|
||||
f"(score: {best_score})"
|
||||
)
|
||||
matched_epg_row = try_epg_name_match(
|
||||
channel_data,
|
||||
best_score,
|
||||
best_epg,
|
||||
top_candidates,
|
||||
is_bulk_matching=False,
|
||||
use_ml=True,
|
||||
)
|
||||
if matched_epg_row:
|
||||
match_via = matched_epg_row["tvg_id"]
|
||||
elif not channel_data["norm_chan"]:
|
||||
logger.debug(f"Channel {channel.id} '{channel.name}' => empty after normalization, skipping")
|
||||
|
||||
if not matched_epg_row:
|
||||
has_fuzzy_epg = _active_epg_fuzzy_queryset().exists()
|
||||
if not has_fuzzy_epg and not channel_data["tvg_id"] and not channel_data["gracenote_id"]:
|
||||
message = "No EPG data available for matching (from active sources)"
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
if matched_epg_row:
|
||||
try:
|
||||
matched_epg_id = matched_epg_row["id"]
|
||||
epg_data = (
|
||||
channel.epg_data
|
||||
if channel.epg_data_id == matched_epg_id
|
||||
else EPGData.objects.get(id=matched_epg_id)
|
||||
)
|
||||
|
||||
if channel.epg_data_id == matched_epg_id:
|
||||
success_msg = (
|
||||
f"Channel '{channel.name}' already matched with EPG '{epg_data.name}'"
|
||||
)
|
||||
if match_via:
|
||||
success_msg += f" (matched via: {match_via})"
|
||||
logger.info(success_msg)
|
||||
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||
send_single_channel_epg_match_result(
|
||||
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||
)
|
||||
return {
|
||||
"matched": True,
|
||||
"unchanged": True,
|
||||
"message": success_msg,
|
||||
"epg_name": epg_data.name,
|
||||
"epg_id": epg_data.id,
|
||||
}
|
||||
|
||||
channel.epg_data = epg_data
|
||||
channel.save(update_fields=["epg_data"])
|
||||
|
||||
success_msg = f"Channel '{channel.name}' matched with EPG '{epg_data.name}'"
|
||||
if match_via:
|
||||
success_msg += f" (matched via: {match_via})"
|
||||
|
||||
logger.info(success_msg)
|
||||
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||
channel.refresh_from_db()
|
||||
send_single_channel_epg_match_result(
|
||||
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||
)
|
||||
return {
|
||||
"matched": True,
|
||||
"message": success_msg,
|
||||
"epg_name": epg_data.name,
|
||||
"epg_id": epg_data.id,
|
||||
}
|
||||
except EPGData.DoesNotExist:
|
||||
message = "Matched EPG data not found"
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||
message = f"No suitable EPG match found for channel '{channel.name}'"
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True)
|
||||
message = f"Error during matching: {str(e)}"
|
||||
send_single_channel_epg_match_result(
|
||||
channel_id,
|
||||
False,
|
||||
message,
|
||||
channel=channel,
|
||||
)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
finally:
|
||||
cleanup_after_matching()
|
||||
55
apps/channels/managers.py
Normal file
55
apps/channels/managers.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""
|
||||
Queryset helpers that resolve effective Channel field values.
|
||||
|
||||
Each Channel can optionally have a related ChannelOverride row carrying user
|
||||
edits to any subset of its user-facing fields. Sync never touches the override
|
||||
row; provider metadata flows directly into Channel.* and the override table
|
||||
sits alongside with a nullable value per field. The helpers here coalesce the
|
||||
two sources into `effective_*` annotations so output querysets can sort,
|
||||
filter, and emit values correctly at SQL level (avoiding 23+ Python-side
|
||||
resolutions across the codebase).
|
||||
"""
|
||||
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
OVERRIDABLE_FIELDS = (
|
||||
"name",
|
||||
"channel_number",
|
||||
"channel_group_id",
|
||||
"logo_id",
|
||||
"tvg_id",
|
||||
"tvc_guide_stationid",
|
||||
"epg_data_id",
|
||||
"stream_profile_id",
|
||||
)
|
||||
|
||||
|
||||
def with_effective_values(queryset, select_related_fks=False):
|
||||
"""
|
||||
Annotate the channels queryset with `effective_*` columns that resolve to
|
||||
the override value when set, otherwise fall back to the channel's own
|
||||
value. Always eagerly loads the override one-to-one to avoid N+1 when the
|
||||
caller reads annotated attributes and then the related override.
|
||||
|
||||
Pass `select_related_fks=True` when the output path will access FK objects
|
||||
through the `effective_*_obj` Channel properties; this pulls the override's
|
||||
logo, channel_group, epg_data, and stream_profile in the same query so
|
||||
those accessors do not trigger per-row lookups.
|
||||
"""
|
||||
annotations = {
|
||||
f"effective_{field}": Coalesce(
|
||||
f"override__{field}",
|
||||
field,
|
||||
)
|
||||
for field in OVERRIDABLE_FIELDS
|
||||
}
|
||||
qs = queryset.select_related("override").annotate(**annotations)
|
||||
if select_related_fks:
|
||||
qs = qs.select_related(
|
||||
"override__logo",
|
||||
"override__channel_group",
|
||||
"override__epg_data",
|
||||
"override__stream_profile",
|
||||
)
|
||||
return qs
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 6.0.4 on 2026-04-21 14:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0034_remove_stream_dispatcharr_stream_id_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='name',
|
||||
field=models.CharField(max_length=512),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stream',
|
||||
name='name',
|
||||
field=models.CharField(default='Default Stream', max_length=512),
|
||||
),
|
||||
]
|
||||
18
apps/channels/migrations/0036_alter_stream_name.py
Normal file
18
apps/channels/migrations/0036_alter_stream_name.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 6.0.4 on 2026-04-30 19:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0035_alter_channel_name_alter_stream_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stream',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, default='Default Stream', max_length=512),
|
||||
),
|
||||
]
|
||||
162
apps/channels/migrations/0037_auto_sync_overhaul.py
Normal file
162
apps/channels/migrations/0037_auto_sync_overhaul.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""
|
||||
Auto-sync overhaul (FR #1196): per-field channel overrides, hide-from-output
|
||||
flag, configurable auto-sync number range, and nullable channel_number for
|
||||
compact-numbering slot release.
|
||||
|
||||
Bundled operations (in forward order; reversed on rollback):
|
||||
1. AddField Channel.hidden_from_output
|
||||
2. CreateModel ChannelOverride (one-to-one Channel)
|
||||
3. AddField ChannelGroupM3UAccount.auto_sync_channel_end
|
||||
4. RunPython backfill_auto_created_by_null (orphan re-attribution / demotion)
|
||||
5. AlterField Channel.channel_number (nullable)
|
||||
6. RunPython noop / reverse_backfill_channel_number_nulls
|
||||
(rollback-safety hook; runs FIRST on un-apply, fills NULLs
|
||||
so step 5 reverse can re-impose NOT NULL)
|
||||
"""
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Max
|
||||
|
||||
|
||||
def backfill_auto_created_by_null(apps, schema_editor):
|
||||
"""
|
||||
Re-attribute or demote `auto_created=True, auto_created_by=NULL` rows.
|
||||
|
||||
Sync only touches rows where `auto_created_by=account`, so orphans
|
||||
accumulate indefinitely. Best-effort re-attribute via the channel's
|
||||
streams' single owning account; otherwise demote to manual by clearing
|
||||
`auto_created`. The channel and any user customization survive; sync
|
||||
will not touch a demoted row again.
|
||||
"""
|
||||
Channel = apps.get_model("dispatcharr_channels", "Channel")
|
||||
ChannelStream = apps.get_model("dispatcharr_channels", "ChannelStream")
|
||||
|
||||
orphans = Channel.objects.filter(auto_created=True, auto_created_by__isnull=True)
|
||||
total = orphans.count()
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
print(f"\n Found {total} auto_created channels with NULL auto_created_by")
|
||||
reattributed = 0
|
||||
demoted = 0
|
||||
|
||||
for channel in orphans.iterator(chunk_size=200):
|
||||
account_ids = set(
|
||||
ChannelStream.objects.filter(channel=channel)
|
||||
.values_list("stream__m3u_account_id", flat=True)
|
||||
)
|
||||
account_ids.discard(None)
|
||||
|
||||
if len(account_ids) == 1:
|
||||
channel.auto_created_by_id = next(iter(account_ids))
|
||||
channel.save(update_fields=["auto_created_by"])
|
||||
reattributed += 1
|
||||
else:
|
||||
channel.auto_created = False
|
||||
channel.save(update_fields=["auto_created"])
|
||||
demoted += 1
|
||||
|
||||
print(
|
||||
f" Re-attributed: {reattributed}, demoted to manual "
|
||||
f"(ambiguous/no streams): {demoted}"
|
||||
)
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
||||
|
||||
|
||||
def reverse_auto_created_by_null(apps, schema_editor):
|
||||
# Forward decisions cannot be cleanly reverted (no record of the
|
||||
# original NULL state). Leaving the re-attributions and demotions in
|
||||
# place is safer than restoring NULLs the schema may not accept.
|
||||
pass
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
def reverse_backfill_channel_number_nulls(apps, schema_editor):
|
||||
"""
|
||||
Rollback-safety hook for the channel_number nullable AlterField.
|
||||
|
||||
Runs FIRST on un-apply (operations reverse in list order) and assigns
|
||||
sequential channel numbers above the current max to any NULL rows so
|
||||
the AlterField reverse (nullable to NOT NULL) succeeds without a
|
||||
constraint violation. The user can re-hide or re-number these
|
||||
channels after they have rolled back.
|
||||
"""
|
||||
Channel = apps.get_model("dispatcharr_channels", "Channel")
|
||||
null_qs = Channel.objects.filter(channel_number__isnull=True)
|
||||
null_count = null_qs.count()
|
||||
if null_count == 0:
|
||||
return
|
||||
|
||||
max_num = Channel.objects.aggregate(m=Max("channel_number"))["m"] or 0.0
|
||||
next_num = float(max_num) + 1.0
|
||||
print(
|
||||
f"\n Backfilling channel_number on {null_count} NULL row(s) "
|
||||
f"starting at {int(next_num)} so rollback can re-impose NOT NULL"
|
||||
)
|
||||
for ch in null_qs.order_by("id"):
|
||||
ch.channel_number = next_num
|
||||
ch.save(update_fields=["channel_number"])
|
||||
next_num += 1.0
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '022_default_user_limit_settings'),
|
||||
('dispatcharr_channels', '0036_alter_stream_name'),
|
||||
('epg', '0022_alter_epgdata_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='hidden_from_output',
|
||||
field=models.BooleanField(
|
||||
db_index=True,
|
||||
default=False,
|
||||
help_text='Exclude this channel from downstream client output (HDHR, M3U, EPG, XC). Auto-sync still updates provider metadata.',
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChannelOverride',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('channel_number', models.FloatField(blank=True, null=True)),
|
||||
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('tvc_guide_stationid', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('channel', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='override', to='dispatcharr_channels.channel')),
|
||||
('channel_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dispatcharr_channels.channelgroup')),
|
||||
('epg_data', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='epg.epgdata')),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dispatcharr_channels.logo')),
|
||||
('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.streamprofile')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='auto_sync_channel_end',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text='Optional upper bound for auto-created channel numbers in this group. Leave blank for unlimited fill. Overflow streams are skipped and reported in the completion notification.',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(backfill_auto_created_by_null, reverse_auto_created_by_null),
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='channel_number',
|
||||
field=models.FloatField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.RunPython(noop, reverse_backfill_channel_number_nulls),
|
||||
]
|
||||
|
|
@ -2,9 +2,9 @@ from django.db import models
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
from apps.proxy.ts_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.ts_proxy.constants import ChannelMetadataField
|
||||
from core.utils import RedisClient, custom_properties_as_dict
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
import logging
|
||||
import uuid
|
||||
from django.utils import timezone
|
||||
|
|
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# If you have an M3UAccount model in apps.m3u, you can still import it:
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.m3u.connection_pool import reserve_profile_slot, release_profile_slot
|
||||
|
||||
|
||||
# Add fallback functions if Redis isn't available
|
||||
|
|
@ -56,7 +57,7 @@ class Stream(models.Model):
|
|||
Represents a single stream (e.g. from an M3U source or custom URL).
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255, default="Default Stream")
|
||||
name = models.CharField(max_length=512, default="Default Stream", db_index=True)
|
||||
url = models.URLField(max_length=4096, blank=True, null=True)
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
|
|
@ -209,13 +210,14 @@ class Stream(models.Model):
|
|||
Finds an available profile for this stream and reserves a connection slot.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
|
||||
Tuple[Optional[int], Optional[int], Optional[str], bool]:
|
||||
(stream_id, profile_id, error_reason, slot_reserved)
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
profile_id = redis_client.get(f"stream_profile:{self.id}")
|
||||
if profile_id:
|
||||
profile_id = int(profile_id)
|
||||
return self.id, profile_id, None
|
||||
return self.id, profile_id, None, False
|
||||
|
||||
# Retrieve the M3U account associated with the stream.
|
||||
m3u_account = self.m3u_account
|
||||
|
|
@ -226,29 +228,22 @@ class Stream(models.Model):
|
|||
]
|
||||
|
||||
for profile in profiles:
|
||||
logger.info(profile)
|
||||
logger.debug("Evaluating profile %s for stream %s", profile.id, self.id)
|
||||
# Skip inactive profiles
|
||||
if profile.is_active == False:
|
||||
continue
|
||||
|
||||
# Atomic slot reservation: INCR first, check, rollback if over capacity
|
||||
if profile.max_streams == 0:
|
||||
reserved = True
|
||||
else:
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
new_count = redis_client.incr(profile_connections_key)
|
||||
if new_count <= profile.max_streams:
|
||||
reserved = True
|
||||
else:
|
||||
redis_client.decr(profile_connections_key)
|
||||
reserved = False
|
||||
# Atomic slot reservation via shared connection pool helper
|
||||
reserved, _count, _failure_reason = reserve_profile_slot(
|
||||
profile, redis_client
|
||||
)
|
||||
|
||||
if reserved:
|
||||
redis_client.set(f"channel_stream:{self.id}", self.id)
|
||||
redis_client.set(f"stream_profile:{self.id}", profile.id)
|
||||
return self.id, profile.id, None
|
||||
return self.id, profile.id, None, True
|
||||
|
||||
return None, None, "All active M3U profiles have reached maximum connection limits"
|
||||
return None, None, "All active M3U profiles have reached maximum connection limits", False
|
||||
|
||||
def release_stream(self):
|
||||
"""
|
||||
|
|
@ -277,12 +272,7 @@ class Stream(models.Model):
|
|||
f"Stream {stream_id}: found profile_id={profile_id}"
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
|
||||
# Only decrement if the profile had a max_connections limit
|
||||
current_count = int(redis_client.get(profile_connections_key) or 0)
|
||||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
release_profile_slot(profile_id, redis_client)
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -291,10 +281,24 @@ class ChannelManager(models.Manager):
|
|||
def active(self):
|
||||
return self.all()
|
||||
|
||||
def with_effective_values(self, select_related_fks=False):
|
||||
"""
|
||||
Chainable shortcut for the override-aware queryset annotations,
|
||||
delegating to the canonical helper in ``apps.channels.managers``
|
||||
so the function form (``with_effective_values(qs)``) and the
|
||||
manager form (``Channel.objects.with_effective_values()``) are
|
||||
both valid entry points and stay in sync.
|
||||
"""
|
||||
from apps.channels.managers import with_effective_values
|
||||
|
||||
return with_effective_values(
|
||||
self.get_queryset(), select_related_fks=select_related_fks
|
||||
)
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
channel_number = models.FloatField(db_index=True)
|
||||
name = models.CharField(max_length=255)
|
||||
channel_number = models.FloatField(db_index=True, null=True, blank=True)
|
||||
name = models.CharField(max_length=512)
|
||||
logo = models.ForeignKey(
|
||||
"Logo",
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
@ -360,6 +364,16 @@ class Channel(models.Model):
|
|||
help_text="The M3U account that auto-created this channel"
|
||||
)
|
||||
|
||||
# Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries.
|
||||
# Auto-sync still recognizes them so they are not recreated when their
|
||||
# underlying provider stream persists; this is an output-layer concern, not
|
||||
# a sync-time flag.
|
||||
hidden_from_output = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Exclude this channel from downstream client output (HDHR, M3U, EPG, XC). Auto-sync still updates provider metadata."
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Timestamp when this channel was created"
|
||||
|
|
@ -369,6 +383,8 @@ class Channel(models.Model):
|
|||
help_text="Timestamp when this channel was last updated"
|
||||
)
|
||||
|
||||
objects = ChannelManager()
|
||||
|
||||
def clean(self):
|
||||
# Enforce unique channel_number within a given group
|
||||
existing = Channel.objects.filter(
|
||||
|
|
@ -384,15 +400,50 @@ class Channel(models.Model):
|
|||
|
||||
@classmethod
|
||||
def get_next_available_channel_number(cls, starting_from=1):
|
||||
used_numbers = set(cls.objects.all().values_list("channel_number", flat=True))
|
||||
n = starting_from
|
||||
while n in used_numbers:
|
||||
n += 1
|
||||
return n
|
||||
# Both raw and override channel numbers are reserved. Handing out a
|
||||
# raw number currently masked by an override would create a deferred
|
||||
# collision once the override is cleared.
|
||||
from apps.channels.compact_numbering import build_reserved_set
|
||||
from apps.m3u.tasks import _next_available_number
|
||||
|
||||
reserved = build_reserved_set()
|
||||
return _next_available_number(reserved, starting_from)
|
||||
|
||||
def _resolved_override(self):
|
||||
"""Return the related ChannelOverride or None, tolerant of no row."""
|
||||
try:
|
||||
return self.override
|
||||
except ChannelOverride.DoesNotExist:
|
||||
return None
|
||||
|
||||
def _resolve_effective_fk(self, field_name):
|
||||
"""Pick the override's FK object if set, otherwise the channel's own."""
|
||||
override = self._resolved_override()
|
||||
if override is not None:
|
||||
override_val = getattr(override, field_name, None)
|
||||
if override_val is not None:
|
||||
return override_val
|
||||
return getattr(self, field_name)
|
||||
|
||||
@property
|
||||
def effective_logo_obj(self):
|
||||
return self._resolve_effective_fk("logo")
|
||||
|
||||
@property
|
||||
def effective_channel_group_obj(self):
|
||||
return self._resolve_effective_fk("channel_group")
|
||||
|
||||
@property
|
||||
def effective_epg_data_obj(self):
|
||||
return self._resolve_effective_fk("epg_data")
|
||||
|
||||
@property
|
||||
def effective_stream_profile_obj(self):
|
||||
return self._resolve_effective_fk("stream_profile")
|
||||
|
||||
# @TODO: honor stream's stream profile
|
||||
def get_stream_profile(self):
|
||||
stream_profile = self.stream_profile
|
||||
stream_profile = self.effective_stream_profile_obj
|
||||
if not stream_profile:
|
||||
stream_profile = StreamProfile.objects.get(
|
||||
id=CoreSettings.get_default_stream_profile_id()
|
||||
|
|
@ -416,7 +467,7 @@ class Channel(models.Model):
|
|||
candidates = []
|
||||
|
||||
# 1) Try to get active channel IDs for this profile from an index set if available
|
||||
ch_set_key = f"ts_proxy:profile:{profile_id}:channels"
|
||||
ch_set_key = f"live:profile:{profile_id}:channels"
|
||||
try:
|
||||
ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) }
|
||||
except Exception:
|
||||
|
|
@ -428,7 +479,7 @@ class Channel(models.Model):
|
|||
# 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id
|
||||
if not ch_ids:
|
||||
cursor = 0
|
||||
pattern = "ts_proxy:channel:*:metadata"
|
||||
pattern = "live:channel:*:metadata"
|
||||
while True:
|
||||
cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500)
|
||||
if keys:
|
||||
|
|
@ -444,7 +495,7 @@ class Channel(models.Model):
|
|||
pid = None
|
||||
|
||||
if pid == profile_id:
|
||||
parts = k.split(":") # ts_proxy:channel:{id}:metadata
|
||||
parts = k.split(":") # live:channel:{id}:metadata
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
ch_ids.add(int(parts[2]))
|
||||
|
|
@ -465,7 +516,7 @@ class Channel(models.Model):
|
|||
continue
|
||||
|
||||
# Skip if recently preempted
|
||||
last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt"
|
||||
last_preempt_key = f"live:channel:{ch_id}:last_preempt"
|
||||
try:
|
||||
last_preempt = float(redis_client.get(last_preempt_key) or 0.0)
|
||||
except Exception:
|
||||
|
|
@ -474,14 +525,14 @@ class Channel(models.Model):
|
|||
continue
|
||||
|
||||
# Clients and their levels
|
||||
clients_key = f"ts_proxy:channel:{ch_id}:clients"
|
||||
clients_key = f"live:channel:{ch_id}:clients"
|
||||
member_ids = list(redis_client.smembers(clients_key) or [])
|
||||
viewer_count = len(member_ids)
|
||||
max_viewer_level = 0
|
||||
if viewer_count:
|
||||
pipe = redis_client.pipeline()
|
||||
for cid in member_ids:
|
||||
pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level")
|
||||
pipe.hget(f"live:channel:{ch_id}:clients:{cid}", "user_level")
|
||||
levels_raw = pipe.execute()
|
||||
levels = []
|
||||
for lv in levels_raw:
|
||||
|
|
@ -496,7 +547,7 @@ class Channel(models.Model):
|
|||
continue
|
||||
|
||||
# Metadata (protected/recording/started_at_ts)
|
||||
meta_key = f"ts_proxy:channel:{ch_id}:metadata"
|
||||
meta_key = f"live:channel:{ch_id}:metadata"
|
||||
try:
|
||||
protected, recording, started_at_ts = redis_client.hmget(
|
||||
meta_key, "protected", "recording", "started_at_ts"
|
||||
|
|
@ -532,50 +583,97 @@ class Channel(models.Model):
|
|||
|
||||
# Mark preempt timestamp to avoid thrashing
|
||||
try:
|
||||
redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600)
|
||||
redis_client.set(f"live:channel:{victim_id}:last_preempt", str(time.time()), ex=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return victim_id
|
||||
|
||||
def _check_and_reserve_profile_slot(self, profile, redis_client):
|
||||
def _channel_proxy_is_active(self, redis_client) -> bool:
|
||||
"""True when live proxy metadata shows this channel is still running."""
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
if not redis_client.exists(metadata_key):
|
||||
return False
|
||||
state = redis_client.hget(metadata_key, ChannelMetadataField.STATE)
|
||||
if state is None:
|
||||
return False
|
||||
if isinstance(state, bytes):
|
||||
state = state.decode()
|
||||
return state in (
|
||||
ChannelState.ACTIVE,
|
||||
ChannelState.WAITING_FOR_CLIENTS,
|
||||
ChannelState.BUFFERING,
|
||||
ChannelState.INITIALIZING,
|
||||
ChannelState.CONNECTING,
|
||||
)
|
||||
|
||||
def _stream_assignment_is_reusable(self, redis_client, stream_id: int) -> bool:
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
Return True when an existing channel_stream assignment should be reused.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET + check + INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Args:
|
||||
profile: M3UAccountProfile instance
|
||||
redis_client: Redis client instance
|
||||
|
||||
Returns:
|
||||
tuple: (reserved: bool, current_count: int)
|
||||
Reuse when the proxy is active, or when metadata is not written yet
|
||||
(between get_stream() reserving slots and initialize_channel() starting).
|
||||
When metadata exists but the proxy is inactive, the assignment is stale.
|
||||
"""
|
||||
if profile.max_streams == 0:
|
||||
return (True, 0)
|
||||
if self._channel_proxy_is_active(redis_client):
|
||||
return True
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
if not redis_client.exists(metadata_key):
|
||||
return redis_client.get(f"stream_profile:{stream_id}") is not None
|
||||
|
||||
# Atomically increment first — this is a single Redis command
|
||||
new_count = redis_client.incr(profile_connections_key)
|
||||
return False
|
||||
|
||||
if new_count <= profile.max_streams:
|
||||
return (True, new_count)
|
||||
def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None:
|
||||
"""Release pool counters and remove stale channel/stream assignment keys."""
|
||||
profile_id = None
|
||||
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if profile_id_bytes:
|
||||
try:
|
||||
profile_id = int(profile_id_bytes)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Invalid profile ID for stale assignment on stream %s: %s",
|
||||
stream_id,
|
||||
profile_id_bytes,
|
||||
)
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
redis_client.decr(profile_connections_key)
|
||||
return (False, new_count - 1)
|
||||
if profile_id is None:
|
||||
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
|
||||
meta_profile_id = redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.M3U_PROFILE
|
||||
)
|
||||
if meta_profile_id:
|
||||
try:
|
||||
profile_id = int(meta_profile_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Invalid profile ID in metadata for stale assignment on "
|
||||
"channel %s: %s",
|
||||
self.uuid,
|
||||
meta_profile_id,
|
||||
)
|
||||
|
||||
if profile_id is not None:
|
||||
release_profile_slot(profile_id, redis_client)
|
||||
else:
|
||||
logger.warning(
|
||||
"Channel %s: releasing stale stream %s assignment without profile "
|
||||
"info - profile_connections may leak",
|
||||
self.uuid,
|
||||
stream_id,
|
||||
)
|
||||
|
||||
redis_client.delete(f"channel_stream:{self.id}")
|
||||
redis_client.delete(f"stream_profile:{stream_id}")
|
||||
|
||||
def get_stream(self, requester=None):
|
||||
"""
|
||||
Finds an available stream for the requested channel and returns the selected stream and profile.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason)
|
||||
Tuple[Optional[int], Optional[int], Optional[str], bool]:
|
||||
(stream_id, profile_id, error_reason, slot_reserved)
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
error_reason = None
|
||||
|
|
@ -583,26 +681,42 @@ class Channel(models.Model):
|
|||
# Check if this channel has any streams
|
||||
if not self.streams.exists():
|
||||
error_reason = "No streams assigned to channel"
|
||||
return None, None, error_reason
|
||||
return None, None, error_reason, False
|
||||
|
||||
# Check if a stream is already active for this channel
|
||||
# Reuse assignment only when this channel is still active in the proxy.
|
||||
# Stale channel_stream keys after stop/disconnect skip INCR and break pool
|
||||
# accounting, which lets a second stream reach the provider and fail validation.
|
||||
stream_id_bytes = redis_client.get(f"channel_stream:{self.id}")
|
||||
if stream_id_bytes:
|
||||
try:
|
||||
stream_id = int(stream_id_bytes)
|
||||
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if profile_id_bytes:
|
||||
try:
|
||||
profile_id = int(profile_id_bytes)
|
||||
return stream_id, profile_id, None
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
f"Invalid stream ID retrieved from Redis: {stream_id_bytes}"
|
||||
)
|
||||
stream_id = None
|
||||
|
||||
if stream_id is not None:
|
||||
if self._stream_assignment_is_reusable(redis_client, stream_id):
|
||||
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if profile_id_bytes:
|
||||
try:
|
||||
profile_id = int(profile_id_bytes)
|
||||
logger.debug(
|
||||
f"Channel {self.uuid}: reusing stream assignment "
|
||||
f"stream={stream_id} profile={profile_id}"
|
||||
)
|
||||
return stream_id, profile_id, None, False
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Channel {self.uuid}: releasing stale stream assignment "
|
||||
f"(stream={stream_id}, proxy not active)"
|
||||
)
|
||||
self._release_stale_stream_assignment(redis_client, stream_id)
|
||||
|
||||
# No existing active stream, attempt to assign a new one
|
||||
has_streams_but_maxed_out = False
|
||||
|
|
@ -636,7 +750,7 @@ class Channel(models.Model):
|
|||
has_active_profiles = True
|
||||
|
||||
# Atomically check and reserve a slot (INCR-first pattern)
|
||||
reserved, current_count = self._check_and_reserve_profile_slot(
|
||||
reserved, current_count, failure_reason = reserve_profile_slot(
|
||||
profile, redis_client
|
||||
)
|
||||
|
||||
|
|
@ -644,11 +758,16 @@ class Channel(models.Model):
|
|||
# Slot reserved — assign stream to this channel
|
||||
redis_client.set(f"channel_stream:{self.id}", stream.id)
|
||||
redis_client.set(f"stream_profile:{stream.id}", profile.id)
|
||||
logger.info(
|
||||
f"Channel {self.uuid}: assigned stream {stream.id} "
|
||||
f"profile {profile.id} ({profile.name})"
|
||||
)
|
||||
|
||||
return (
|
||||
stream.id,
|
||||
profile.id,
|
||||
None,
|
||||
True,
|
||||
) # Return newly assigned stream and matched profile
|
||||
else:
|
||||
# At capacity: try to preempt a lower-impact channel on this profile
|
||||
|
|
@ -662,12 +781,21 @@ class Channel(models.Model):
|
|||
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
|
||||
# return self.id, profile.id, victim_channel_id
|
||||
|
||||
# This profile is at max connections
|
||||
has_streams_but_maxed_out = True
|
||||
logger.debug(
|
||||
f"Profile {profile.id} at max connections: "
|
||||
f"{current_count}/{profile.max_streams}"
|
||||
)
|
||||
if failure_reason == "profile_full":
|
||||
logger.info(
|
||||
f"Profile {profile.id} at max connections: "
|
||||
f"{current_count}/{profile.max_streams}, trying next profile"
|
||||
)
|
||||
elif failure_reason == "credential_full":
|
||||
logger.info(
|
||||
f"Profile {profile.id} shared login pool full, trying next profile"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Profile {profile.id} reservation failed: "
|
||||
f"{current_count}/{profile.max_streams}"
|
||||
)
|
||||
|
||||
# No available streams - determine specific reason
|
||||
if has_streams_but_maxed_out:
|
||||
|
|
@ -677,7 +805,7 @@ class Channel(models.Model):
|
|||
else:
|
||||
error_reason = "No active profiles found for any assigned stream"
|
||||
|
||||
return None, None, error_reason
|
||||
return None, None, error_reason, False
|
||||
|
||||
def release_stream(self):
|
||||
"""
|
||||
|
|
@ -721,12 +849,7 @@ class Channel(models.Model):
|
|||
ChannelMetadataField.M3U_PROFILE,
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
current_count = int(
|
||||
redis_client.get(profile_connections_key) or 0
|
||||
)
|
||||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
release_profile_slot(profile_id, redis_client)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
|
|
@ -771,12 +894,7 @@ class Channel(models.Model):
|
|||
f"stream {stream_id}"
|
||||
)
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
|
||||
# Only decrement if the profile had a max_connections limit
|
||||
current_count = int(redis_client.get(profile_connections_key) or 0)
|
||||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
release_profile_slot(profile_id, redis_client)
|
||||
|
||||
# Clear metadata fields so duplicate release_stream() calls
|
||||
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
|
||||
|
|
@ -822,10 +940,31 @@ class Channel(models.Model):
|
|||
if current_profile_id == new_profile_id:
|
||||
return True
|
||||
|
||||
# Use pipeline for atomic profile switch to prevent counter drift
|
||||
# if an exception occurs between DECR and INCR
|
||||
old_profile_connections_key = f"profile_connections:{current_profile_id}"
|
||||
new_profile_connections_key = f"profile_connections:{new_profile_id}"
|
||||
from apps.m3u.connection_pool import (
|
||||
move_credential_slot_on_profile_switch,
|
||||
profile_connections_key,
|
||||
)
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
|
||||
old_profile = M3UAccountProfile.objects.select_related(
|
||||
"m3u_account__server_group"
|
||||
).get(id=current_profile_id)
|
||||
new_profile = M3UAccountProfile.objects.select_related(
|
||||
"m3u_account__server_group"
|
||||
).get(id=new_profile_id)
|
||||
|
||||
if not move_credential_slot_on_profile_switch(
|
||||
old_profile, new_profile, redis_client
|
||||
):
|
||||
logger.warning(
|
||||
"Shared login pool full for profile %s during stream profile switch",
|
||||
new_profile_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Profile counters always move on switch; credential totals move only when login changes.
|
||||
old_profile_connections_key = profile_connections_key(current_profile_id)
|
||||
new_profile_connections_key = profile_connections_key(new_profile_id)
|
||||
old_count = int(redis_client.get(old_profile_connections_key) or 0)
|
||||
|
||||
pipe = redis_client.pipeline()
|
||||
|
|
@ -840,6 +979,76 @@ class Channel(models.Model):
|
|||
return True
|
||||
|
||||
|
||||
class ChannelOverride(models.Model):
|
||||
"""
|
||||
Per-field user overrides for auto-synced channels.
|
||||
|
||||
Each nullable column represents a user-provided value that takes precedence
|
||||
over the matching field on the related Channel. Sync writes only to
|
||||
Channel.* fields and never to this table, so provider metadata keeps
|
||||
flowing while user customizations persist across refreshes. Output
|
||||
querysets resolve the effective value via
|
||||
`apps.channels.managers.with_effective_values`.
|
||||
"""
|
||||
channel = models.OneToOneField(
|
||||
Channel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="override",
|
||||
)
|
||||
name = models.CharField(max_length=512, null=True, blank=True)
|
||||
channel_number = models.FloatField(null=True, blank=True)
|
||||
channel_group = models.ForeignKey(
|
||||
ChannelGroup,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
logo = models.ForeignKey(
|
||||
"Logo",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
tvc_guide_stationid = models.CharField(max_length=255, null=True, blank=True)
|
||||
epg_data = models.ForeignKey(
|
||||
EPGData,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
stream_profile = models.ForeignKey(
|
||||
StreamProfile,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Override for channel {self.channel_id}"
|
||||
|
||||
def has_any_override(self) -> bool:
|
||||
return any(
|
||||
getattr(self, field) is not None
|
||||
for field in (
|
||||
"name",
|
||||
"channel_number",
|
||||
"channel_group_id",
|
||||
"logo_id",
|
||||
"tvg_id",
|
||||
"tvc_guide_stationid",
|
||||
"epg_data_id",
|
||||
"stream_profile_id",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ChannelProfile(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
|
||||
|
|
@ -887,6 +1096,12 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
blank=True,
|
||||
help_text='Starting channel number for auto-created channels in this group'
|
||||
)
|
||||
# Optional upper bound; out-of-range streams fail. NULL = unlimited.
|
||||
auto_sync_channel_end = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Optional upper bound for auto-created channel numbers in this group. Leave blank for unlimited fill. Overflow streams are skipped and reported in the completion notification.'
|
||||
)
|
||||
last_seen = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
db_index=True,
|
||||
|
|
@ -904,6 +1119,13 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Logo(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .models import (
|
|||
Stream,
|
||||
Channel,
|
||||
ChannelGroup,
|
||||
ChannelOverride,
|
||||
ChannelStream,
|
||||
ChannelGroupM3UAccount,
|
||||
Logo,
|
||||
|
|
@ -17,10 +18,11 @@ from .models import (
|
|||
from apps.epg.serializers import EPGDataSerializer
|
||||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
from django.db import connection, transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
|
||||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -55,32 +57,43 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
# Cache-busting: append a short hash of the logo's source URL so the browser
|
||||
# fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
|
||||
# The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
|
||||
# See SD integration PR notes for context on why this was added.
|
||||
import hashlib
|
||||
url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
|
||||
base_path = reverse("api:channels:logo-cache", args=[obj.id])
|
||||
cache_url = f"{base_path}?v={url_hash}"
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[obj.id])
|
||||
)
|
||||
return reverse("api:channels:logo-cache", args=[obj.id])
|
||||
return build_absolute_uri_with_port(request, cache_url)
|
||||
return cache_url
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
# `channel_count` is provided as an annotation in LogoViewSet.get_queryset().
|
||||
# Fall back to a query only when serializing a single un-annotated Logo
|
||||
# (e.g. nested inside ChannelSerializer.get_logo()).
|
||||
annotated = getattr(obj, "channel_count", None)
|
||||
if annotated is not None:
|
||||
return annotated
|
||||
return obj.channels.count()
|
||||
|
||||
def get_is_used(self, obj):
|
||||
"""Check if this logo is used by any channels"""
|
||||
return obj.channels.exists()
|
||||
return self.get_channel_count(obj) > 0
|
||||
|
||||
def get_channel_names(self, obj):
|
||||
"""Get the names of channels using this logo (limited to first 5)"""
|
||||
names = []
|
||||
|
||||
# Get channel names
|
||||
channels = obj.channels.all()[:5]
|
||||
# When LogoViewSet.get_queryset() prefetches `channels`, iterating
|
||||
# obj.channels.all() reuses the cached set; slicing happens in Python.
|
||||
channels = list(obj.channels.all()[:5])
|
||||
for channel in channels:
|
||||
names.append(f"Channel: {channel.name}")
|
||||
|
||||
# Calculate total count for "more" message
|
||||
total_count = self.get_channel_count(obj)
|
||||
if total_count > 5:
|
||||
names.append(f"...and {total_count - 5} more")
|
||||
|
|
@ -154,12 +167,52 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
|||
m3u_accounts = serializers.IntegerField(source="m3u_accounts.id", read_only=True)
|
||||
enabled = serializers.BooleanField()
|
||||
auto_channel_sync = serializers.BooleanField(default=False)
|
||||
auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False)
|
||||
auto_sync_channel_start = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=1
|
||||
)
|
||||
auto_sync_channel_end = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=1
|
||||
)
|
||||
custom_properties = serializers.JSONField(required=False)
|
||||
# Provider stream count for this group+account. Lets users size an
|
||||
# optional end-range without first running a blind sync.
|
||||
stream_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroupM3UAccount
|
||||
fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties", "is_stale", "last_seen"]
|
||||
fields = [
|
||||
"m3u_accounts",
|
||||
"channel_group",
|
||||
"enabled",
|
||||
"auto_channel_sync",
|
||||
"auto_sync_channel_start",
|
||||
"auto_sync_channel_end",
|
||||
"custom_properties",
|
||||
"is_stale",
|
||||
"last_seen",
|
||||
"stream_count",
|
||||
]
|
||||
|
||||
def get_stream_count(self, obj):
|
||||
"""
|
||||
Return the number of streams for this (m3u_account, channel_group)
|
||||
pair. A parent serializer (e.g. M3UAccountSerializer) may seed
|
||||
``context["stream_counts"]`` with a pre-aggregated dict keyed by
|
||||
``(m3u_account_id, channel_group_id)`` to avoid one COUNT per row;
|
||||
when present, it is used as the source of truth. The per-row
|
||||
COUNT fallback is correct for stand-alone serialization (rare,
|
||||
low-volume) and exists so direct ChannelGroupM3UAccount queries
|
||||
do not require callers to know the seeding pattern.
|
||||
"""
|
||||
counts = self.context.get("stream_counts")
|
||||
if counts is not None:
|
||||
return counts.get((obj.m3u_account_id, obj.channel_group_id), 0)
|
||||
from apps.channels.models import Stream
|
||||
|
||||
return Stream.objects.filter(
|
||||
m3u_account_id=obj.m3u_account_id,
|
||||
channel_group_id=obj.channel_group_id,
|
||||
).count()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
|
|
@ -168,16 +221,25 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
|||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both dict and JSON string for custom_properties (for backward compatibility)
|
||||
val = data.get("custom_properties")
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
data["custom_properties"] = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return super().to_internal_value(data)
|
||||
def validate(self, attrs):
|
||||
# Partial PATCHes only carry submitted fields; fill missing
|
||||
# start/end from the instance so the validator catches a PATCH
|
||||
# that lowers end past the existing start.
|
||||
start = attrs.get("auto_sync_channel_start")
|
||||
end = attrs.get("auto_sync_channel_end")
|
||||
if start is None and self.instance is not None:
|
||||
start = self.instance.auto_sync_channel_start
|
||||
if end is None and self.instance is not None:
|
||||
end = self.instance.auto_sync_channel_end
|
||||
if start is not None and end is not None and end < start:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"auto_sync_channel_end": (
|
||||
"End must be greater than or equal to start."
|
||||
)
|
||||
}
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
#
|
||||
# Channel Group
|
||||
|
|
@ -195,12 +257,14 @@ class ChannelGroupSerializer(serializers.ModelSerializer):
|
|||
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get count of channels in this group"""
|
||||
return obj.channels.count()
|
||||
# Use the queryset annotation when available (list path); fall back
|
||||
# to a live query for retrieve/create/update where it isn't set.
|
||||
v = getattr(obj, 'channel_count', None)
|
||||
return v if v is not None else obj.channels.count()
|
||||
|
||||
def get_m3u_account_count(self, obj):
|
||||
"""Get count of M3U accounts associated with this group"""
|
||||
return obj.m3u_accounts.count()
|
||||
v = getattr(obj, 'm3u_account_count', None)
|
||||
return v if v is not None else obj.m3u_accounts.count()
|
||||
|
||||
|
||||
class ChannelProfileSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -211,10 +275,15 @@ class ChannelProfileSerializer(serializers.ModelSerializer):
|
|||
fields = ["id", "name", "channels"]
|
||||
|
||||
def get_channels(self, obj):
|
||||
memberships = ChannelProfileMembership.objects.filter(
|
||||
channel_profile=obj, enabled=True
|
||||
# Use prefetched attr when available, fall back to a direct query.
|
||||
memberships = getattr(obj, 'enabled_memberships', None)
|
||||
if memberships is not None:
|
||||
return [m.channel_id for m in memberships]
|
||||
return list(
|
||||
ChannelProfileMembership.objects.filter(
|
||||
channel_profile=obj, enabled=True
|
||||
).values_list('channel_id', flat=True)
|
||||
)
|
||||
return [membership.channel.id for membership in memberships]
|
||||
|
||||
|
||||
class ChannelProfileMembershipSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -240,6 +309,62 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer):
|
|||
return value
|
||||
|
||||
|
||||
#
|
||||
# Channel override
|
||||
#
|
||||
# Nullable per-field overrides resolved over the parent Channel in read
|
||||
# paths. Embedded in ChannelSerializer so clients can upsert/clear in the
|
||||
# same PATCH that targets direct channel fields.
|
||||
class ChannelOverrideSerializer(serializers.ModelSerializer):
|
||||
# HDHR clients reject negative GuideNumber and zero is not a real
|
||||
# provider value, so reject both at the API boundary.
|
||||
channel_number = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=0.0001
|
||||
)
|
||||
channel_group_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelGroup.objects.all(),
|
||||
source="channel_group",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
logo_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Logo.objects.all(),
|
||||
source="logo",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
epg_data_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EPGData.objects.all(),
|
||||
source="epg_data",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
source="stream_profile",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ChannelOverride
|
||||
fields = [
|
||||
"name",
|
||||
"channel_number",
|
||||
"channel_group_id",
|
||||
"logo_id",
|
||||
"tvg_id",
|
||||
"tvc_guide_stationid",
|
||||
"epg_data_id",
|
||||
"stream_profile_id",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_null": True, "required": False},
|
||||
"tvg_id": {"allow_null": True, "required": False},
|
||||
"tvc_guide_stationid": {"allow_null": True, "required": False},
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Channel
|
||||
#
|
||||
|
|
@ -280,6 +405,32 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
|
||||
auto_created_by_name = serializers.SerializerMethodField()
|
||||
override = ChannelOverrideSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text=(
|
||||
"Per-field overrides for an auto-created channel. "
|
||||
'Send {"override": {"name": "ESPN"}} to upsert the listed '
|
||||
'fields, {"override": {"name": null}} to clear specific fields '
|
||||
'while leaving others, or {"override": null} to delete the '
|
||||
"override row entirely. Omitting the key leaves any existing "
|
||||
"override unchanged. Only valid for auto_created=True channels. "
|
||||
"Duplicate channel_number values across channels are permitted; "
|
||||
"downstream client behavior on duplicates varies by client."
|
||||
),
|
||||
)
|
||||
source_stream = serializers.SerializerMethodField()
|
||||
# Effective fields coalesce override over channel column. Consumers
|
||||
# display these; raw fields remain in the response so the edit form
|
||||
# can show them as "Provider: X" subtext.
|
||||
effective_name = serializers.SerializerMethodField()
|
||||
effective_channel_number = serializers.SerializerMethodField()
|
||||
effective_channel_group_id = serializers.SerializerMethodField()
|
||||
effective_logo_id = serializers.SerializerMethodField()
|
||||
effective_tvg_id = serializers.SerializerMethodField()
|
||||
effective_tvc_guide_stationid = serializers.SerializerMethodField()
|
||||
effective_epg_data_id = serializers.SerializerMethodField()
|
||||
effective_stream_profile_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Channel
|
||||
|
|
@ -297,11 +448,88 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"logo_id",
|
||||
"user_level",
|
||||
"is_adult",
|
||||
"hidden_from_output",
|
||||
"auto_created",
|
||||
"auto_created_by",
|
||||
"auto_created_by_name",
|
||||
"override",
|
||||
"source_stream",
|
||||
"effective_name",
|
||||
"effective_channel_number",
|
||||
"effective_channel_group_id",
|
||||
"effective_logo_id",
|
||||
"effective_tvg_id",
|
||||
"effective_tvc_guide_stationid",
|
||||
"effective_epg_data_id",
|
||||
"effective_stream_profile_id",
|
||||
]
|
||||
|
||||
def _effective_value(self, obj, field_name):
|
||||
override = getattr(obj, "_channel_override_cache", None)
|
||||
if override is None:
|
||||
try:
|
||||
override = obj.override
|
||||
except ChannelOverride.DoesNotExist:
|
||||
override = None
|
||||
obj._channel_override_cache = override
|
||||
if override is not None:
|
||||
value = getattr(override, field_name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return getattr(obj, field_name, None)
|
||||
|
||||
def get_effective_name(self, obj):
|
||||
return self._effective_value(obj, "name")
|
||||
|
||||
def get_effective_channel_number(self, obj):
|
||||
return self._effective_value(obj, "channel_number")
|
||||
|
||||
def get_effective_channel_group_id(self, obj):
|
||||
return self._effective_value(obj, "channel_group_id")
|
||||
|
||||
def get_effective_logo_id(self, obj):
|
||||
return self._effective_value(obj, "logo_id")
|
||||
|
||||
def get_effective_tvg_id(self, obj):
|
||||
return self._effective_value(obj, "tvg_id")
|
||||
|
||||
def get_effective_tvc_guide_stationid(self, obj):
|
||||
return self._effective_value(obj, "tvc_guide_stationid")
|
||||
|
||||
def get_effective_epg_data_id(self, obj):
|
||||
return self._effective_value(obj, "epg_data_id")
|
||||
|
||||
def get_effective_stream_profile_id(self, obj):
|
||||
return self._effective_value(obj, "stream_profile_id")
|
||||
|
||||
def get_source_stream(self, obj):
|
||||
"""
|
||||
Return the originating provider stream for an auto-created channel.
|
||||
|
||||
Surfaces the provider stream's name and owning M3U account so the
|
||||
frontend can render "Auto-created from: <provider> / <stream name>"
|
||||
in the channel edit form. Returns None for manual channels.
|
||||
"""
|
||||
if not self.context.get("include_source_stream", False):
|
||||
return None
|
||||
if not obj.auto_created:
|
||||
return None
|
||||
# Viewset prefetches `channelstream_set` ordered by `order`, so
|
||||
# `.all()[0]` reuses the cache and returns the lowest-order entry.
|
||||
prefetched_list = list(obj.channelstream_set.all())
|
||||
if not prefetched_list:
|
||||
return None
|
||||
cs = prefetched_list[0]
|
||||
if not cs.stream:
|
||||
return None
|
||||
stream = cs.stream
|
||||
return {
|
||||
"id": stream.id,
|
||||
"name": stream.name,
|
||||
"account_id": stream.m3u_account_id,
|
||||
"account_name": getattr(stream.m3u_account, "name", None),
|
||||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
include_streams = self.context.get("include_streams", False)
|
||||
|
||||
|
|
@ -309,27 +537,31 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
self.fields["streams"] = serializers.SerializerMethodField()
|
||||
return super().to_representation(instance)
|
||||
else:
|
||||
# Fix: For PATCH/PUT responses, ensure streams are ordered
|
||||
# Read from the prefetched channelstream_set (ordered by the
|
||||
# viewset's Prefetch); chaining .order_by() rebuilds the
|
||||
# queryset and fires one SELECT per row in list responses.
|
||||
representation = super().to_representation(instance)
|
||||
if "streams" in representation:
|
||||
representation["streams"] = list(
|
||||
instance.streams.all()
|
||||
.order_by("channelstream__order")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
representation["streams"] = [
|
||||
cs.stream_id for cs in instance.channelstream_set.all()
|
||||
]
|
||||
return representation
|
||||
|
||||
def get_logo(self, obj):
|
||||
return LogoSerializer(obj.logo).data
|
||||
|
||||
def get_streams(self, obj):
|
||||
"""Retrieve ordered stream IDs for GET requests."""
|
||||
return StreamSerializer(
|
||||
obj.streams.all().order_by("channelstream__order"), many=True
|
||||
).data
|
||||
"""Retrieve ordered streams for GET requests using prefetched channelstream_set."""
|
||||
ordered_streams = [
|
||||
cs.stream
|
||||
for cs in obj.channelstream_set.all()
|
||||
if cs.stream_id is not None
|
||||
]
|
||||
return StreamSerializer(ordered_streams, many=True).data
|
||||
|
||||
def create(self, validated_data):
|
||||
streams = validated_data.pop("streams", [])
|
||||
override_data = validated_data.pop("override", None)
|
||||
channel_number = validated_data.pop(
|
||||
"channel_number", Channel.get_next_available_channel_number()
|
||||
)
|
||||
|
|
@ -341,57 +573,146 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group")
|
||||
validated_data["channel_group"] = default_group
|
||||
|
||||
channel = Channel.objects.create(**validated_data)
|
||||
# Atomic wrapper keeps the channel insert and its override row
|
||||
# in the same transaction so a failure on either rolls both back.
|
||||
with transaction.atomic():
|
||||
channel = Channel.objects.create(**validated_data)
|
||||
|
||||
# Add streams in the specified order
|
||||
for index, stream in enumerate(streams):
|
||||
ChannelStream.objects.create(
|
||||
channel=channel, stream_id=stream.id, order=index
|
||||
)
|
||||
# Add streams in the specified order
|
||||
for index, stream in enumerate(streams):
|
||||
ChannelStream.objects.create(
|
||||
channel=channel, stream_id=stream.id, order=index
|
||||
)
|
||||
|
||||
if override_data:
|
||||
# Manual channels (auto_created=False) have no provider
|
||||
# value to override; reject the override payload here so a
|
||||
# programmatic client can't write a semantically meaningless
|
||||
# row that the frontend would then surface as "Overrides
|
||||
# active".
|
||||
if not channel.auto_created:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"override": (
|
||||
"Cannot set override on a manual channel; "
|
||||
"overrides only apply to auto-created channels."
|
||||
)
|
||||
}
|
||||
)
|
||||
obj = ChannelOverride.objects.create(channel=channel, **override_data)
|
||||
# Drop an all-null override row; an empty override would
|
||||
# falsely surface as active in the UI.
|
||||
if not obj.has_any_override():
|
||||
obj.delete()
|
||||
|
||||
return channel
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
PATCH handler for Channel rows. The ``override`` key carries
|
||||
per-field user overrides for auto-created channels and follows
|
||||
these rules:
|
||||
|
||||
* key absent from payload: no change to existing overrides
|
||||
* ``{"override": {"field": value}}``: upsert those fields
|
||||
* ``{"override": {"field": null}}``: clear those specific fields
|
||||
* ``{"override": null}``: delete the override row entirely
|
||||
|
||||
Key presence is what distinguishes "no change" from "delete";
|
||||
an explicit null means delete. Override mutations are rejected
|
||||
on manual channels (auto_created=False) since there is no
|
||||
provider value to override.
|
||||
"""
|
||||
streams = validated_data.pop("streams", None)
|
||||
has_override_key = "override" in self.initial_data
|
||||
override_data = validated_data.pop("override", None)
|
||||
|
||||
# Update standard fields
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save()
|
||||
|
||||
if streams is not None:
|
||||
# Normalize stream IDs
|
||||
normalized_ids = [
|
||||
stream.id if hasattr(stream, "id") else stream for stream in streams
|
||||
]
|
||||
print(normalized_ids)
|
||||
|
||||
# Get current mapping of stream_id -> ChannelStream
|
||||
current_links = {
|
||||
cs.stream_id: cs for cs in instance.channelstream_set.all()
|
||||
}
|
||||
|
||||
# Track existing stream IDs
|
||||
existing_ids = set(current_links.keys())
|
||||
new_ids = set(normalized_ids)
|
||||
|
||||
# Delete any links not in the new list
|
||||
to_remove = existing_ids - new_ids
|
||||
if to_remove:
|
||||
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
|
||||
|
||||
# Update or create with new order
|
||||
for order, stream_id in enumerate(normalized_ids):
|
||||
if stream_id in current_links:
|
||||
cs = current_links[stream_id]
|
||||
if cs.order != order:
|
||||
cs.order = order
|
||||
cs.save(update_fields=["order"])
|
||||
else:
|
||||
ChannelStream.objects.create(
|
||||
channel=instance, stream_id=stream_id, order=order
|
||||
# Block override mutations on manual channels (no provider
|
||||
# value to override). Clearing is a tolerated no-op.
|
||||
if (
|
||||
has_override_key
|
||||
and override_data is not None
|
||||
and override_data != {}
|
||||
and not instance.auto_created
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"override": (
|
||||
"Cannot set override on a manual channel; "
|
||||
"overrides only apply to auto-created channels."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Atomic so a failure on the override row rolls back the
|
||||
# channel update too.
|
||||
with transaction.atomic():
|
||||
# Skip save() when only override keys were submitted; a
|
||||
# no-op UPDATE would bump updated_at and bust caches.
|
||||
if validated_data:
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
if has_override_key:
|
||||
if override_data is None:
|
||||
# Explicit null: remove the override row.
|
||||
ChannelOverride.objects.filter(channel=instance).delete()
|
||||
elif override_data == {}:
|
||||
# Empty dict has no field intent; no-op.
|
||||
pass
|
||||
else:
|
||||
obj, _ = ChannelOverride.objects.update_or_create(
|
||||
channel=instance, defaults=override_data
|
||||
)
|
||||
# Drop an all-null override; would falsely surface
|
||||
# as active in the UI.
|
||||
if not obj.has_any_override():
|
||||
obj.delete()
|
||||
# Queryset writes leave the reverse-OneToOne cache stale;
|
||||
# clear it so to_representation reads the new state.
|
||||
try:
|
||||
instance._state.fields_cache.pop("override", None)
|
||||
except AttributeError:
|
||||
pass
|
||||
if hasattr(instance, "_channel_override_cache"):
|
||||
delattr(instance, "_channel_override_cache")
|
||||
|
||||
if streams is not None:
|
||||
# Normalize stream IDs
|
||||
normalized_ids = [
|
||||
stream.id if hasattr(stream, "id") else stream for stream in streams
|
||||
]
|
||||
|
||||
# Get current mapping of stream_id -> ChannelStream
|
||||
current_links = {
|
||||
cs.stream_id: cs for cs in instance.channelstream_set.all()
|
||||
}
|
||||
|
||||
# Track existing stream IDs
|
||||
existing_ids = set(current_links.keys())
|
||||
new_ids = set(normalized_ids)
|
||||
|
||||
# Delete any links not in the new list
|
||||
to_remove = existing_ids - new_ids
|
||||
if to_remove:
|
||||
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
|
||||
|
||||
# Update or create with new order
|
||||
to_update = []
|
||||
for order, stream_id in enumerate(normalized_ids):
|
||||
if stream_id in current_links:
|
||||
cs = current_links[stream_id]
|
||||
if cs.order != order:
|
||||
cs.order = order
|
||||
to_update.append(cs)
|
||||
else:
|
||||
ChannelStream.objects.create(
|
||||
channel=instance, stream_id=stream_id, order=order
|
||||
)
|
||||
|
||||
if to_update:
|
||||
ChannelStream.objects.bulk_update(to_update, ["order"])
|
||||
|
||||
return instance
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# apps/channels/signals.py
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now, is_aware, make_aware
|
||||
from celery.result import AsyncResult
|
||||
|
|
@ -60,6 +60,137 @@ def generate_custom_stream_hash(sender, instance, created, **kwargs):
|
|||
# Use update to avoid triggering signals again
|
||||
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
|
||||
|
||||
@receiver(pre_delete, sender=Channel)
|
||||
def stop_proxy_session_before_channel_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a Channel is deleted, stop any active TS proxy session for it first.
|
||||
|
||||
Without this, the proxy's Redis state (live:channel:{uuid}:*) survives
|
||||
the Channel row and the connected clients' "Stop" button hits
|
||||
`ChannelService.stop_channel(uuid)`, which calls `Channel.objects.get(uuid=...)`
|
||||
and crashes with DoesNotExist (reported as 'Channel not found' in the UI).
|
||||
Users then cannot close the stream; source-side connection limits stay
|
||||
consumed. Covers manual deletes, bulk deletes, and sync-driven deletes
|
||||
via the same signal path.
|
||||
"""
|
||||
try:
|
||||
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
||||
|
||||
channel_uuid = str(instance.uuid) if instance.uuid else None
|
||||
if not channel_uuid:
|
||||
return
|
||||
# Best-effort: if the channel has no active session the service
|
||||
# returns a benign 'Channel not found' result, which is ignored.
|
||||
ChannelService.stop_channel(channel_uuid)
|
||||
except Exception as e:
|
||||
# Never block a channel delete on proxy cleanup failure. Log and
|
||||
# continue so at least the DB row is removed.
|
||||
logger.warning(
|
||||
"Failed to stop proxy session before deleting channel %s: %s",
|
||||
getattr(instance, "id", "<unknown>"),
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Channel)
|
||||
def assign_compact_number_on_unhide(sender, instance, created, **kwargs):
|
||||
"""When a channel transitions from hidden to visible under compact
|
||||
numbering, immediately assign the next available number from its
|
||||
group's range. Without this, an unhide would leave the channel at
|
||||
NULL until the next M3U refresh, which is too long a delay for what
|
||||
is meant to feel like an instant action.
|
||||
|
||||
Bails out for the common cases where assignment is not appropriate:
|
||||
a fresh channel (newly created), a channel that already has a
|
||||
number, a hidden channel, a manual channel, or a channel whose group
|
||||
is not in compact mode. The assignment helper does the same checks
|
||||
defensively, but bailing here keeps the signal out of the helper
|
||||
code path on every channel save.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
# Skip the signal when update_fields proves hidden_from_output was not
|
||||
# touched. Sync sets update_fields on every save, so this keeps the
|
||||
# signal off the sync hot path.
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None and "hidden_from_output" not in update_fields:
|
||||
return
|
||||
if instance.hidden_from_output:
|
||||
return
|
||||
if instance.channel_number is not None:
|
||||
return
|
||||
if not instance.auto_created or not instance.auto_created_by_id:
|
||||
return
|
||||
try:
|
||||
from .compact_numbering import assign_compact_numbers_for_channels
|
||||
|
||||
assign_compact_numbers_for_channels([instance.id])
|
||||
# The helper writes via queryset .update() which skips
|
||||
# in-memory state. Reload so a same-request serializer
|
||||
# response carries the assigned number, not stale None.
|
||||
try:
|
||||
instance.refresh_from_db(fields=["channel_number"])
|
||||
except Exception:
|
||||
# Refresh failure (race with delete) is non-fatal.
|
||||
pass
|
||||
except Exception as e:
|
||||
# Do not propagate. The save succeeded; the assignment is
|
||||
# recoverable on next sync or manual repack.
|
||||
logger.warning(
|
||||
"Compact unhide assignment failed for channel %s: %s",
|
||||
instance.id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Channel)
|
||||
def release_compact_number_on_hide(sender, instance, created, **kwargs):
|
||||
"""When a channel transitions from visible to hidden under compact
|
||||
numbering, immediately release its channel_number slot. Without
|
||||
this, the slot stays occupied until the next sync's repack pass,
|
||||
so the user sees their hidden channels still consuming numbers in
|
||||
the table for an indeterminate window. Mirror image of
|
||||
`assign_compact_number_on_unhide` above.
|
||||
|
||||
Bails out for the common cases where release is not appropriate:
|
||||
a fresh channel (newly created), a non-hidden channel, a channel
|
||||
that has no number to release, a manual channel, a channel without
|
||||
a known auto_created_by, or a channel whose group is not in
|
||||
compact mode.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None and "hidden_from_output" not in update_fields:
|
||||
return
|
||||
if not instance.hidden_from_output:
|
||||
return
|
||||
if instance.channel_number is None:
|
||||
return
|
||||
if not instance.auto_created or not instance.auto_created_by_id:
|
||||
return
|
||||
try:
|
||||
from .compact_numbering import (
|
||||
get_group_relation_for_channel,
|
||||
is_compact_group,
|
||||
)
|
||||
|
||||
relation = get_group_relation_for_channel(instance)
|
||||
if not relation or not is_compact_group(relation):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
# Release the slot. Queryset .update() bypasses the post_save signal
|
||||
# chain that this handler is itself running inside.
|
||||
Channel.objects.filter(id=instance.id).update(channel_number=None)
|
||||
# Refresh in-memory instance so the same-request serializer
|
||||
# response surfaces channel_number=None instead of stale value.
|
||||
try:
|
||||
instance.refresh_from_db(fields=["channel_number"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@receiver(post_save, sender=Channel)
|
||||
def refresh_epg_programs(sender, instance, created, **kwargs):
|
||||
"""
|
||||
|
|
@ -70,10 +201,14 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
|
|||
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
|
||||
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
|
||||
if instance.epg_data:
|
||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
|
||||
return
|
||||
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
# For new channels with EPG data, also refresh
|
||||
elif created and instance.epg_data:
|
||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
|
||||
return
|
||||
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,11 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
from apps.channels.models import Channel, ChannelGroup
|
||||
from apps.channels.models import Channel, ChannelGroup, ChannelOverride
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -209,3 +211,365 @@ class ChannelBulkEditAPITests(TestCase):
|
|||
self.assertEqual(self.channel1.name, "Only Name Changed")
|
||||
self.assertEqual(self.channel1.channel_number, original_channel_number)
|
||||
self.assertEqual(self.channel1.tvg_id, original_tvg_id)
|
||||
|
||||
def test_bulk_swap_clear_and_assign_same_number(self):
|
||||
# User clears channel A's override (which currently pins #10) and
|
||||
# in the same bulk request sets channel B's override.channel_number
|
||||
# to #10. Both halves of the swap must succeed; the resulting
|
||||
# state has A unpinned and B pinned at #10.
|
||||
auto_a = Channel.objects.create(
|
||||
channel_number=1.0,
|
||||
name="Auto A",
|
||||
tvg_id="auto_a",
|
||||
channel_group=self.group1,
|
||||
auto_created=True,
|
||||
)
|
||||
ChannelOverride.objects.create(channel=auto_a, channel_number=10.0)
|
||||
auto_b = Channel.objects.create(
|
||||
channel_number=2.0,
|
||||
name="Auto B",
|
||||
tvg_id="auto_b",
|
||||
channel_group=self.group1,
|
||||
auto_created=True,
|
||||
)
|
||||
|
||||
data = [
|
||||
{"id": auto_a.id, "override": None},
|
||||
{"id": auto_b.id, "override": {"channel_number": 10.0}},
|
||||
]
|
||||
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
||||
|
||||
self.assertEqual(
|
||||
response.status_code,
|
||||
status.HTTP_200_OK,
|
||||
f"Expected 200; got {response.status_code} body={response.data}",
|
||||
)
|
||||
self.assertFalse(
|
||||
ChannelOverride.objects.filter(channel=auto_a).exists()
|
||||
)
|
||||
b_override = ChannelOverride.objects.get(channel=auto_b)
|
||||
self.assertEqual(b_override.channel_number, 10.0)
|
||||
|
||||
|
||||
class ChannelSummaryEffectiveValuesTests(TestCase):
|
||||
"""
|
||||
The /api/channels/channels/summary/ endpoint feeds the TV Guide.
|
||||
Like every downstream output surface, it must reflect the user's
|
||||
overrides (name, channel_number, logo_id, epg_data_id,
|
||||
channel_group_id) instead of the raw provider values, otherwise
|
||||
the in-app guide would silently disagree with HDHR / M3U / EPG /
|
||||
XC clients on the same channel set.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from apps.channels.models import ChannelOverride
|
||||
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="summary_admin", password="x"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.group = ChannelGroup.objects.create(name="Summary Group")
|
||||
self.other_group = ChannelGroup.objects.create(name="Other")
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=10.0,
|
||||
name="Provider Name",
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
)
|
||||
ChannelOverride.objects.create(
|
||||
channel=self.channel,
|
||||
name="Override Name",
|
||||
channel_number=99.0,
|
||||
channel_group=self.other_group,
|
||||
)
|
||||
|
||||
def test_summary_returns_effective_values(self):
|
||||
response = self.client.get("/api/channels/channels/summary/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
row = next(r for r in response.data if r["id"] == self.channel.id)
|
||||
self.assertEqual(row["name"], "Override Name")
|
||||
self.assertEqual(row["channel_number"], 99.0)
|
||||
self.assertEqual(row["channel_group_id"], self.other_group.id)
|
||||
|
||||
|
||||
class ChannelManagerEffectiveValuesTests(TestCase):
|
||||
"""
|
||||
The chainable ``Channel.objects.with_effective_values()`` shortcut
|
||||
must return rows with the same ``effective_*`` annotations the
|
||||
module-level helper produces, since both forms are documented
|
||||
entry points and a divergence would silently change output for
|
||||
one set of callers.
|
||||
"""
|
||||
|
||||
def test_manager_shortcut_matches_module_helper(self):
|
||||
from apps.channels.managers import with_effective_values
|
||||
|
||||
group = ChannelGroup.objects.create(name="Manager Test")
|
||||
channel = Channel.objects.create(
|
||||
channel_number=42.0,
|
||||
name="Original Name",
|
||||
channel_group=group,
|
||||
auto_created=True,
|
||||
)
|
||||
ChannelOverride.objects.create(
|
||||
channel=channel,
|
||||
name="Renamed",
|
||||
channel_number=99.0,
|
||||
)
|
||||
|
||||
helper_row = with_effective_values(
|
||||
Channel.objects.filter(id=channel.id)
|
||||
).get()
|
||||
shortcut_row = (
|
||||
Channel.objects.with_effective_values()
|
||||
.filter(id=channel.id)
|
||||
.get()
|
||||
)
|
||||
|
||||
self.assertEqual(helper_row.effective_name, "Renamed")
|
||||
self.assertEqual(shortcut_row.effective_name, "Renamed")
|
||||
self.assertEqual(helper_row.effective_channel_number, 99.0)
|
||||
self.assertEqual(shortcut_row.effective_channel_number, 99.0)
|
||||
self.assertEqual(
|
||||
helper_row.effective_channel_group_id,
|
||||
shortcut_row.effective_channel_group_id,
|
||||
)
|
||||
|
||||
|
||||
class SeriesRuleAPITests(TestCase):
|
||||
"""API tests for series rule CRUD and bulk-remove endpoints."""
|
||||
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(username="admin_sr", password="pass")
|
||||
self.admin.user_level = 10
|
||||
self.admin.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
|
||||
from core.models import CoreSettings
|
||||
CoreSettings.set_dvr_series_rules([])
|
||||
|
||||
self.rules_url = "/api/channels/series-rules/"
|
||||
self.bulk_remove_url = "/api/channels/series-rules/bulk-remove/"
|
||||
|
||||
# --- POST (create/upsert) ---
|
||||
|
||||
def test_create_rule_with_tvg_id(self):
|
||||
resp = self.client.post(self.rules_url, {
|
||||
"tvg_id": "some.channel", "title": "My Show", "mode": "all",
|
||||
}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(resp.data["rules"]), 1)
|
||||
self.assertEqual(resp.data["rules"][0]["tvg_id"], "some.channel")
|
||||
|
||||
def test_create_title_only_rule_no_tvg_id(self):
|
||||
"""A rule with no tvg_id (title-only) is accepted when title is provided."""
|
||||
resp = self.client.post(self.rules_url, {
|
||||
"tvg_id": "", "title": "Untethered Show", "mode": "all",
|
||||
}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
rule = resp.data["rules"][0]
|
||||
self.assertEqual(rule["tvg_id"], "")
|
||||
self.assertEqual(rule["title"], "Untethered Show")
|
||||
|
||||
def test_create_rule_requires_title_or_description(self):
|
||||
resp = self.client.post(self.rules_url, {
|
||||
"tvg_id": "some.channel", "title": "", "description": "",
|
||||
}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_upsert_key_is_tvg_id_and_title(self):
|
||||
"""Two POST requests with same tvg_id but different titles create two rules."""
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
||||
}, format="json")
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "ch.1", "title": "Show B", "mode": "all",
|
||||
}, format="json")
|
||||
resp = self.client.get(self.rules_url)
|
||||
self.assertEqual(len(resp.data["rules"]), 2)
|
||||
|
||||
def test_upsert_updates_existing_rule(self):
|
||||
"""POSTing with an existing (tvg_id, title) pair updates in place."""
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
||||
}, format="json")
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "ch.1", "title": "Show A", "mode": "new",
|
||||
}, format="json")
|
||||
resp = self.client.get(self.rules_url)
|
||||
self.assertEqual(len(resp.data["rules"]), 1)
|
||||
self.assertEqual(resp.data["rules"][0]["mode"], "new")
|
||||
|
||||
# --- DELETE (query params) ---
|
||||
|
||||
def test_delete_rule_by_tvg_id_and_title(self):
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
||||
}, format="json")
|
||||
resp = self.client.delete(
|
||||
self.rules_url + "?tvg_id=ch.1&title=Show+A"
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["rules"], [])
|
||||
|
||||
def test_delete_title_only_rule(self):
|
||||
"""Title-only rules (tvg_id='') are deleted via empty tvg_id query param."""
|
||||
self.client.post(self.rules_url, {
|
||||
"tvg_id": "", "title": "Untethered Show", "mode": "all",
|
||||
}, format="json")
|
||||
resp = self.client.delete(
|
||||
self.rules_url + "?tvg_id=&title=Untethered+Show"
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["rules"], [])
|
||||
|
||||
def test_delete_only_removes_matching_rule(self):
|
||||
"""Delete by (tvg_id, title) leaves other rules intact."""
|
||||
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
|
||||
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show B", "mode": "all"}, format="json")
|
||||
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
|
||||
resp = self.client.get(self.rules_url)
|
||||
self.assertEqual(len(resp.data["rules"]), 1)
|
||||
self.assertEqual(resp.data["rules"][0]["title"], "Show B")
|
||||
|
||||
def test_delete_removes_future_recordings(self):
|
||||
"""DELETE cleans up future recordings that matched the rule."""
|
||||
from apps.channels.models import Recording
|
||||
|
||||
group = ChannelGroup.objects.create(name="G")
|
||||
channel = Channel.objects.create(channel_number=1, name="Ch", channel_group=group)
|
||||
now = timezone.now()
|
||||
Recording.objects.create(
|
||||
channel=channel,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=2),
|
||||
custom_properties={"program": {"tvg_id": "ch.1", "title": "Show A"}},
|
||||
)
|
||||
|
||||
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
|
||||
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
|
||||
self.assertEqual(Recording.objects.count(), 0)
|
||||
|
||||
# --- POST bulk-remove ---
|
||||
|
||||
def test_bulk_remove_with_tvg_id(self):
|
||||
from apps.channels.models import Recording
|
||||
|
||||
group = ChannelGroup.objects.create(name="G2")
|
||||
channel = Channel.objects.create(channel_number=2, name="Ch2", channel_group=group)
|
||||
now = timezone.now()
|
||||
Recording.objects.create(
|
||||
channel=channel,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=2),
|
||||
custom_properties={"program": {"tvg_id": "ch.x", "title": "Show X"}},
|
||||
)
|
||||
resp = self.client.post(self.bulk_remove_url, {"tvg_id": "ch.x"}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["removed"], 1)
|
||||
self.assertEqual(Recording.objects.count(), 0)
|
||||
|
||||
def test_bulk_remove_title_only_no_tvg_id(self):
|
||||
"""Bulk-remove accepts title alone (no tvg_id) for title-only rules."""
|
||||
from apps.channels.models import Recording
|
||||
|
||||
group = ChannelGroup.objects.create(name="G3")
|
||||
channel = Channel.objects.create(channel_number=3, name="Ch3", channel_group=group)
|
||||
now = timezone.now()
|
||||
Recording.objects.create(
|
||||
channel=channel,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=2),
|
||||
custom_properties={"program": {"tvg_id": "ch.a", "title": "Cross Show"}},
|
||||
)
|
||||
Recording.objects.create(
|
||||
channel=channel,
|
||||
start_time=now + timedelta(hours=3),
|
||||
end_time=now + timedelta(hours=4),
|
||||
custom_properties={"program": {"tvg_id": "ch.b", "title": "Cross Show"}},
|
||||
)
|
||||
resp = self.client.post(self.bulk_remove_url, {"title": "Cross Show"}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["removed"], 2)
|
||||
|
||||
def test_bulk_remove_requires_tvg_id_or_title(self):
|
||||
resp = self.client.post(self.bulk_remove_url, {}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ChannelListIncludeStreamsQueryTests(TestCase):
|
||||
"""include_streams=true must not issue one stream query per channel."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.channels.models import ChannelStream, Stream
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
self.user = User.objects.create_user(username="list_admin", password="x")
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="list-test-account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
self.group = ChannelGroup.objects.create(name=f"List Group {self.id}")
|
||||
|
||||
def _add_channel_with_stream(self, number):
|
||||
from apps.channels.models import ChannelStream, Stream
|
||||
|
||||
channel = Channel.objects.create(
|
||||
channel_number=float(number),
|
||||
name=f"Channel {number}",
|
||||
channel_group=self.group,
|
||||
)
|
||||
stream = Stream.objects.create(
|
||||
name=f"Stream {number}",
|
||||
url=f"http://example.com/{number}.ts",
|
||||
m3u_account=self.account,
|
||||
)
|
||||
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
|
||||
return channel
|
||||
|
||||
def _query_count_for_list(self):
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = self.client.get(
|
||||
"/api/channels/channels/",
|
||||
{"page": 1, "page_size": 50, "include_streams": "true"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
def test_include_streams_query_count_stable_as_channels_grow(self):
|
||||
self._add_channel_with_stream(1)
|
||||
self._add_channel_with_stream(2)
|
||||
self._add_channel_with_stream(3)
|
||||
q_small = self._query_count_for_list()
|
||||
|
||||
self._add_channel_with_stream(4)
|
||||
self._add_channel_with_stream(5)
|
||||
self._add_channel_with_stream(6)
|
||||
self._add_channel_with_stream(7)
|
||||
q_large = self._query_count_for_list()
|
||||
|
||||
self.assertEqual(
|
||||
q_small,
|
||||
q_large,
|
||||
"include_streams list should use prefetched channelstream_set, "
|
||||
"not one streams M2M query per channel",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -196,15 +196,17 @@ class InitialConnectionRetryTests(TestCase):
|
|||
base URL before falling back to the next candidate."""
|
||||
|
||||
def test_reconnect_max_constant_exists_in_run_recording(self):
|
||||
"""run_recording must define a max-reconnect limit to prevent
|
||||
infinite retries on the same broken base URL."""
|
||||
"""run_recording must use a time-bounded FFmpeg outage window to prevent
|
||||
infinite restarts when the source stream is permanently down."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
from apps.channels.tasks import run_recording, _dvr_ffmpeg_retry_window_seconds
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The reconnection counter pattern must be present
|
||||
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
|
||||
self.assertIn("reconnect", source.lower(),
|
||||
"run_recording must contain reconnection logic")
|
||||
"run_recording must contain input reconnection flags")
|
||||
self.assertIn("_ffmpeg_outage_started", source)
|
||||
self.assertIn("_ffmpeg_retry_window", source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,58 +2,68 @@ import os
|
|||
from django.test import SimpleTestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.channels.tasks import build_dvr_candidates
|
||||
from apps.channels.tasks import get_dvr_stream_base_url
|
||||
|
||||
|
||||
class DVRPortResolutionTests(SimpleTestCase):
|
||||
class DVRStreamBaseURLTests(SimpleTestCase):
|
||||
"""
|
||||
Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT
|
||||
environment variable instead of hardcoding port 9191.
|
||||
Tests that get_dvr_stream_base_url() returns the correct single URL
|
||||
for each deployment mode.
|
||||
"""
|
||||
|
||||
@patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True)
|
||||
def test_default_port_uses_9191(self):
|
||||
"""Without DISPATCHARR_PORT set, candidates default to 9191."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://web:9191', candidates)
|
||||
self.assertIn('http://localhost:9191', candidates)
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_aio_default_uses_localhost_5656(self):
|
||||
"""AIO mode (default) reaches uwsgi directly on loopback port 5656."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://127.0.0.1:5656')
|
||||
|
||||
@patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True)
|
||||
def test_custom_port_reflected_in_candidates(self):
|
||||
"""DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://web:8080', candidates)
|
||||
self.assertIn('http://localhost:8080', candidates)
|
||||
self.assertNotIn('http://web:9191', candidates)
|
||||
self.assertNotIn('http://localhost:9191', candidates)
|
||||
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'aio'}, clear=True)
|
||||
def test_aio_explicit_uses_localhost_5656(self):
|
||||
"""Explicit DISPATCHARR_ENV=aio also uses loopback port 5656."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://127.0.0.1:5656')
|
||||
|
||||
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'dev'}, clear=True)
|
||||
def test_dev_mode_uses_localhost_5656(self):
|
||||
"""Dev mode shares the container with uwsgi — uses loopback port 5656."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://127.0.0.1:5656')
|
||||
|
||||
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '9191'}, clear=True)
|
||||
def test_modular_uses_web_service_name(self):
|
||||
"""Modular mode uses the 'web' Docker service name by default."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://web:9191')
|
||||
|
||||
@patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '8080'}, clear=True)
|
||||
def test_modular_custom_port(self):
|
||||
"""Modular mode respects DISPATCHARR_PORT."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://web:8080')
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_PORT': '7777',
|
||||
'DISPATCHARR_ENV': 'dev',
|
||||
'REDIS_HOST': 'redis',
|
||||
'DISPATCHARR_ENV': 'modular',
|
||||
'DISPATCHARR_PORT': '9191',
|
||||
'DISPATCHARR_WEB_HOST': 'dispatcharr_web',
|
||||
}, clear=True)
|
||||
def test_dev_mode_includes_5656_and_custom_port(self):
|
||||
"""Dev mode includes both uwsgi internal port (5656) and custom port."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://127.0.0.1:5656', candidates)
|
||||
self.assertIn('http://127.0.0.1:7777', candidates)
|
||||
def test_modular_custom_web_host(self):
|
||||
"""DISPATCHARR_WEB_HOST overrides the default 'web' service name."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://dispatcharr_web:9191')
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234',
|
||||
'REDIS_HOST': 'redis',
|
||||
'DISPATCHARR_ENV': 'modular',
|
||||
}, clear=True)
|
||||
def test_explicit_override_is_first(self):
|
||||
"""DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertEqual(candidates[0], 'http://custom:1234')
|
||||
def test_explicit_override_always_wins(self):
|
||||
"""DISPATCHARR_INTERNAL_TS_BASE_URL takes priority over all other settings."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://custom:1234')
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
'DISPATCHARR_PORT': '3000',
|
||||
'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000',
|
||||
'REDIS_HOST': 'redis',
|
||||
'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234/',
|
||||
}, clear=True)
|
||||
def test_internal_api_base_overrides_web_fallback(self):
|
||||
"""DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default."""
|
||||
candidates = build_dvr_candidates()
|
||||
self.assertIn('http://myhost:4000', candidates)
|
||||
self.assertNotIn('http://web:3000', candidates)
|
||||
def test_explicit_override_strips_trailing_slash(self):
|
||||
"""Trailing slash is stripped from DISPATCHARR_INTERNAL_TS_BASE_URL."""
|
||||
url = get_dvr_stream_base_url()
|
||||
self.assertEqual(url, 'http://custom:1234')
|
||||
|
|
|
|||
272
apps/channels/tests/test_epg_logo_apply.py
Normal file
272
apps/channels/tests/test_epg_logo_apply.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.channels.models import Channel, Logo
|
||||
from apps.channels.utils import (
|
||||
apply_logos_from_epg_icon_url,
|
||||
apply_logos_from_epg_for_source,
|
||||
auto_apply_epg_logos_enabled,
|
||||
maybe_auto_apply_epg_logos,
|
||||
)
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AutoApplyEpgLogosEnabledTests(TestCase):
|
||||
def test_enabled_when_flag_true(self):
|
||||
self.assertTrue(
|
||||
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': True})
|
||||
)
|
||||
|
||||
def test_disabled_when_flag_false_or_missing(self):
|
||||
self.assertFalse(
|
||||
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': False})
|
||||
)
|
||||
self.assertFalse(auto_apply_epg_logos_enabled({}))
|
||||
self.assertFalse(auto_apply_epg_logos_enabled(None))
|
||||
|
||||
|
||||
class ApplyLogosFromEpgIconUrlTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.epg_one = EPGData.objects.create(
|
||||
tvg_id='ch.one',
|
||||
name='Channel One',
|
||||
icon_url='https://example.com/one.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.epg_two = EPGData.objects.create(
|
||||
tvg_id='ch.two',
|
||||
name='Channel Two',
|
||||
icon_url='https://example.com/one.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel_one = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Channel One',
|
||||
tvg_id='ch.one',
|
||||
epg_data=self.epg_one,
|
||||
)
|
||||
self.channel_two = Channel.objects.create(
|
||||
channel_number=2,
|
||||
name='Channel Two',
|
||||
tvg_id='ch.two',
|
||||
epg_data=self.epg_two,
|
||||
)
|
||||
|
||||
def test_creates_logo_and_updates_channels(self):
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 2)
|
||||
self.assertEqual(stats['created_logos_count'], 1)
|
||||
self.assertEqual(Logo.objects.count(), 1)
|
||||
|
||||
self.channel_one.refresh_from_db()
|
||||
self.channel_two.refresh_from_db()
|
||||
self.assertEqual(self.channel_one.logo.url, 'https://example.com/one.png')
|
||||
self.assertEqual(self.channel_two.logo_id, self.channel_one.logo_id)
|
||||
|
||||
def test_skips_channels_already_using_icon_url(self):
|
||||
existing_logo = Logo.objects.create(
|
||||
name='Existing',
|
||||
url='https://example.com/one.png',
|
||||
)
|
||||
self.channel_one.logo = existing_logo
|
||||
self.channel_one.save(update_fields=['logo'])
|
||||
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.assertEqual(stats['created_logos_count'], 0)
|
||||
|
||||
def test_skips_channels_without_icon_url(self):
|
||||
self.epg_one.icon_url = None
|
||||
self.epg_one.save(update_fields=['icon_url'])
|
||||
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.channel_one.refresh_from_db()
|
||||
self.assertIsNone(self.channel_one.logo_id)
|
||||
|
||||
|
||||
class ApplyLogosForSourceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.other_source = EPGSource.objects.create(
|
||||
name='Other EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/other.xml',
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.other_epg = EPGData.objects.create(
|
||||
tvg_id='other',
|
||||
name='Other',
|
||||
icon_url='https://example.com/other.png',
|
||||
epg_source=self.other_source,
|
||||
)
|
||||
self.mapped_channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
Channel.objects.create(
|
||||
channel_number=2,
|
||||
name='Other',
|
||||
tvg_id='other',
|
||||
epg_data=self.other_epg,
|
||||
)
|
||||
|
||||
def test_only_updates_channels_mapped_to_source(self):
|
||||
stats = apply_logos_from_epg_for_source(self.source)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.mapped_channel.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.mapped_channel.logo.url,
|
||||
'https://example.com/mapped.png',
|
||||
)
|
||||
|
||||
def test_processes_source_in_batches(self):
|
||||
stats = apply_logos_from_epg_for_source(self.source, batch_size=1)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.mapped_channel.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.mapped_channel.logo.url,
|
||||
'https://example.com/mapped.png',
|
||||
)
|
||||
|
||||
|
||||
class MaybeAutoApplyEpgLogosTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
custom_properties={'auto_apply_epg_logos': True},
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
|
||||
def test_runs_when_enabled(self):
|
||||
stats = maybe_auto_apply_epg_logos(self.source)
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertIsNotNone(self.channel.logo_id)
|
||||
|
||||
def test_skips_when_disabled(self):
|
||||
self.source.custom_properties = {'auto_apply_epg_logos': False}
|
||||
self.source.save(update_fields=['custom_properties'])
|
||||
|
||||
stats = maybe_auto_apply_epg_logos(self.source)
|
||||
self.assertIsNone(stats)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertIsNone(self.channel.logo_id)
|
||||
|
||||
class SetLogosFromEpgApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='testuser', password='testpass123')
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.url = '/api/channels/channels/set-logos-from-epg/'
|
||||
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
|
||||
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
|
||||
def test_accepts_channel_ids(self, mock_delay):
|
||||
mock_delay.return_value.id = 'task-1'
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'channel_ids': [self.channel.id]},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_delay.assert_called_once_with(channel_ids=[self.channel.id])
|
||||
|
||||
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
|
||||
def test_accepts_epg_source_id(self, mock_delay):
|
||||
mock_delay.return_value.id = 'task-2'
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'epg_source_id': self.source.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_delay.assert_called_once_with(epg_source_id=self.source.id)
|
||||
self.assertEqual(response.data['channel_count'], 1)
|
||||
|
||||
def test_rejects_both_parameters(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{
|
||||
'channel_ids': [self.channel.id],
|
||||
'epg_source_id': self.source.id,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
58
apps/channels/tests/test_epg_match_apply.py
Normal file
58
apps/channels/tests/test_epg_match_apply.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Tests for applying EPG auto-match results to channels."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.epg_matching import apply_matched_epg_to_channels
|
||||
from apps.channels.models import Channel
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
|
||||
|
||||
class ApplyMatchedEpgToChannelsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name="XML EPG",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
self.epg_one = EPGData.objects.create(
|
||||
tvg_id="ch.one",
|
||||
name="Channel One",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.epg_two = EPGData.objects.create(
|
||||
tvg_id="ch.two",
|
||||
name="Channel Two",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name="Channel One",
|
||||
tvg_id="ch.one",
|
||||
epg_data=self.epg_one,
|
||||
)
|
||||
|
||||
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||
def test_skips_unchanged_assignment(self, mock_delay):
|
||||
changed = apply_matched_epg_to_channels(
|
||||
[{"id": self.channel.id, "epg_data_id": self.epg_one.id}]
|
||||
)
|
||||
|
||||
self.assertEqual(changed, [])
|
||||
mock_delay.assert_not_called()
|
||||
self.channel.refresh_from_db()
|
||||
self.assertEqual(self.channel.epg_data_id, self.epg_one.id)
|
||||
|
||||
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||
def test_updates_changed_assignment_and_dispatches_parse(self, mock_delay):
|
||||
changed = apply_matched_epg_to_channels(
|
||||
[{"id": self.channel.id, "epg_data_id": self.epg_two.id}]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
changed,
|
||||
[{"channel_id": self.channel.id, "epg_data_id": self.epg_two.id}],
|
||||
)
|
||||
mock_delay.assert_called_once_with(self.epg_two.id)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertEqual(self.channel.epg_data_id, self.epg_two.id)
|
||||
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Tests for EPG channel name normalization (prefix/suffix/custom ignore rules)."""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.epg_matching import (
|
||||
build_epg_tvg_id_index,
|
||||
clear_normalize_settings_cache,
|
||||
normalize_name,
|
||||
)
|
||||
from core.models import CoreSettings, EPG_SETTINGS_KEY
|
||||
|
||||
|
||||
class NormalizeNameSettingsTest(TestCase):
|
||||
def _set_epg_settings(self, **kwargs):
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=EPG_SETTINGS_KEY,
|
||||
defaults={"name": "EPG Settings", "value": {}},
|
||||
)
|
||||
current = obj.value if isinstance(obj.value, dict) else {}
|
||||
current.update(kwargs)
|
||||
obj.value = current
|
||||
obj.save()
|
||||
clear_normalize_settings_cache()
|
||||
|
||||
def test_default_mode_does_not_apply_ignore_lists(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="default",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
result_default = normalize_name("HD:HBO Plus East 4K")
|
||||
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
result_advanced = normalize_name("HD:HBO Plus East 4K")
|
||||
|
||||
self.assertNotEqual(result_default, result_advanced)
|
||||
self.assertEqual(result_advanced, "hbo east")
|
||||
|
||||
def test_advanced_mode_strips_prefix(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("HD:ABC 7 (WXYZ) - Springfield"),
|
||||
"abc 7 springfield wxyz",
|
||||
)
|
||||
|
||||
def test_advanced_mode_strips_suffix(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("NBC 5 (KABC) - Metro 4K"),
|
||||
"nbc 5 metro kabc",
|
||||
)
|
||||
|
||||
def test_advanced_mode_removes_custom_strings(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("HBO Plus East"),
|
||||
"hbo east",
|
||||
)
|
||||
|
||||
def test_advanced_mode_applies_prefix_suffix_and_custom_in_order(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["Sling:"],
|
||||
epg_match_ignore_suffixes=[" HD"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("Sling:HBO Plus East HD"),
|
||||
"hbo east",
|
||||
)
|
||||
|
||||
def test_only_first_matching_prefix_is_removed(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:", "SD:"],
|
||||
)
|
||||
self.assertEqual(normalize_name("HD:SD:Channel 5"), "sd channel 5")
|
||||
|
||||
def test_call_sign_preserved_from_original_name(self):
|
||||
self._set_epg_settings(epg_match_mode="default")
|
||||
self.assertEqual(
|
||||
normalize_name("NBC 5 (KABC) - Metro"),
|
||||
"nbc 5 metro kabc",
|
||||
)
|
||||
|
||||
def test_tvg_id_index_prefers_first_entry_when_catalog_sorted_by_priority(self):
|
||||
# Catalog from build_epg_matching_catalog() is highest-priority first.
|
||||
epg_data = [
|
||||
{"id": 2, "tvg_id": "abc.us", "epg_source_priority": 50, "name": "High"},
|
||||
{"id": 1, "tvg_id": "abc.us", "epg_source_priority": 10, "name": "Low"},
|
||||
]
|
||||
index = build_epg_tvg_id_index(epg_data)
|
||||
self.assertEqual(index["abc.us"]["id"], 2)
|
||||
|
||||
def test_settings_cache_refresh_picks_up_new_rules(self):
|
||||
self._set_epg_settings(epg_match_mode="default")
|
||||
self.assertEqual(normalize_name("HD:ABC"), "hdabc")
|
||||
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
)
|
||||
self.assertEqual(normalize_name("HD:ABC"), "abc")
|
||||
173
apps/channels/tests/test_get_stream_assignment.py
Normal file
173
apps/channels/tests/test_get_stream_assignment.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Tests for Channel.get_stream() assignment reuse and stale cleanup."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.models import Channel, ChannelStream, Stream
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
|
||||
|
||||
class FakeAssignmentRedis:
|
||||
"""In-memory Redis for channel_stream assignment tests."""
|
||||
|
||||
def __init__(self):
|
||||
self._strings = {}
|
||||
self._hashes = {}
|
||||
|
||||
def _decode(self, value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode()
|
||||
return value
|
||||
|
||||
def get(self, key):
|
||||
value = self._strings.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return str(value).encode()
|
||||
return str(value).encode()
|
||||
|
||||
def set(self, key, value):
|
||||
self._strings[key] = value
|
||||
|
||||
def delete(self, key):
|
||||
self._strings.pop(key, None)
|
||||
self._hashes.pop(key, None)
|
||||
|
||||
def exists(self, key):
|
||||
return key in self._strings or key in self._hashes
|
||||
|
||||
def hget(self, key, field):
|
||||
return self._hashes.get(key, {}).get(field)
|
||||
|
||||
def hset(self, key, mapping=None, **kwargs):
|
||||
bucket = self._hashes.setdefault(key, {})
|
||||
if mapping:
|
||||
bucket.update(mapping)
|
||||
bucket.update(kwargs)
|
||||
|
||||
def incr(self, key):
|
||||
current = int(self._decode(self.get(key)) or 0)
|
||||
current += 1
|
||||
self._strings[key] = current
|
||||
return current
|
||||
|
||||
def decr(self, key):
|
||||
current = int(self._decode(self.get(key)) or 0)
|
||||
current -= 1
|
||||
self._strings[key] = current
|
||||
return current
|
||||
|
||||
|
||||
class ChannelGetStreamAssignmentTests(TestCase):
|
||||
def setUp(self):
|
||||
self.redis = FakeAssignmentRedis()
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="assignment-test",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
max_streams=5,
|
||||
)
|
||||
self.profile = M3UAccountProfile.objects.get(
|
||||
m3u_account=self.account, is_default=True
|
||||
)
|
||||
self.profile.max_streams = 2
|
||||
self.profile.save()
|
||||
|
||||
self.stream = Stream.objects.create(
|
||||
name="Test Stream",
|
||||
url="http://example.com/live/user/pass/1.ts",
|
||||
m3u_account=self.account,
|
||||
)
|
||||
self.channel = Channel.objects.create(channel_number=501, name="Assignment Ch")
|
||||
ChannelStream.objects.create(channel=self.channel, stream=self.stream, order=0)
|
||||
|
||||
self.metadata_key = RedisKeys.channel_metadata(str(self.channel.uuid))
|
||||
|
||||
def _seed_assignment(self):
|
||||
self.redis.set(f"channel_stream:{self.channel.id}", self.stream.id)
|
||||
self.redis.set(f"stream_profile:{self.stream.id}", self.profile.id)
|
||||
|
||||
@patch("apps.channels.models.RedisClient.get_client")
|
||||
@patch("apps.channels.models.reserve_profile_slot")
|
||||
def test_reuses_assignment_when_proxy_active(
|
||||
self, mock_reserve, mock_get_client
|
||||
):
|
||||
mock_get_client.return_value = self.redis
|
||||
self._seed_assignment()
|
||||
self.redis.hset(
|
||||
self.metadata_key,
|
||||
{ChannelMetadataField.STATE: ChannelState.ACTIVE},
|
||||
)
|
||||
|
||||
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||
|
||||
self.assertEqual(stream_id, self.stream.id)
|
||||
self.assertEqual(profile_id, self.profile.id)
|
||||
self.assertIsNone(error)
|
||||
self.assertFalse(slot_reserved)
|
||||
mock_reserve.assert_not_called()
|
||||
|
||||
@patch("apps.channels.models.RedisClient.get_client")
|
||||
@patch("apps.channels.models.reserve_profile_slot")
|
||||
def test_reuses_assignment_during_init_before_metadata(
|
||||
self, mock_reserve, mock_get_client
|
||||
):
|
||||
mock_get_client.return_value = self.redis
|
||||
self._seed_assignment()
|
||||
|
||||
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||
|
||||
self.assertEqual(stream_id, self.stream.id)
|
||||
self.assertEqual(profile_id, self.profile.id)
|
||||
self.assertIsNone(error)
|
||||
self.assertFalse(slot_reserved)
|
||||
mock_reserve.assert_not_called()
|
||||
|
||||
@patch("apps.channels.models.RedisClient.get_client")
|
||||
@patch("apps.channels.models.release_profile_slot")
|
||||
@patch("apps.channels.models.reserve_profile_slot")
|
||||
def test_releases_stale_assignment_when_proxy_stopped(
|
||||
self, mock_reserve, mock_release, mock_get_client
|
||||
):
|
||||
mock_get_client.return_value = self.redis
|
||||
mock_reserve.return_value = (True, 1, None)
|
||||
self._seed_assignment()
|
||||
self.redis.hset(
|
||||
self.metadata_key,
|
||||
{ChannelMetadataField.STATE: ChannelState.STOPPED},
|
||||
)
|
||||
|
||||
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
|
||||
|
||||
mock_release.assert_called_once_with(self.profile.id, self.redis)
|
||||
mock_reserve.assert_called_once()
|
||||
self.assertEqual(stream_id, self.stream.id)
|
||||
self.assertEqual(profile_id, self.profile.id)
|
||||
self.assertTrue(slot_reserved)
|
||||
|
||||
@patch("apps.channels.models.RedisClient.get_client")
|
||||
def test_stream_assignment_is_reusable_during_init_pending(self, mock_get_client):
|
||||
mock_get_client.return_value = self.redis
|
||||
self._seed_assignment()
|
||||
|
||||
self.assertTrue(
|
||||
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
|
||||
)
|
||||
|
||||
@patch("apps.channels.models.RedisClient.get_client")
|
||||
def test_stream_assignment_not_reusable_when_stopped(self, mock_get_client):
|
||||
mock_get_client.return_value = self.redis
|
||||
self._seed_assignment()
|
||||
self.redis.hset(
|
||||
self.metadata_key,
|
||||
{ChannelMetadataField.STATE: ChannelState.STOPPED},
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
|
||||
)
|
||||
|
|
@ -365,46 +365,44 @@ class RecordingStatusLifecycleTests(TestCase):
|
|||
# =========================================================================
|
||||
|
||||
class ConcatFlagsTests(TestCase):
|
||||
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
|
||||
when concatenating pre-restart segments."""
|
||||
"""Verify error-tolerant FFmpeg flags on the HLS segment concat command."""
|
||||
|
||||
def test_concat_command_includes_error_tolerant_flags(self):
|
||||
"""Inspect the source code to confirm error-tolerant flags are present.
|
||||
This is a static analysis test — no ffmpeg execution needed."""
|
||||
def test_hls_concat_cmd_includes_error_tolerant_flags(self):
|
||||
from apps.channels.tasks import _dvr_build_hls_concat_cmd
|
||||
|
||||
cmd = _dvr_build_hls_concat_cmd("/data/concat.txt", "/data/out.mkv")
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", cmd)
|
||||
self.assertIn("-err_detect", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
|
||||
self.assertIn("-avoid_negative_ts", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-avoid_negative_ts") + 1], "make_zero")
|
||||
self.assertIn("concat", cmd)
|
||||
self.assertEqual(cmd[-1], "/data/out.mkv")
|
||||
|
||||
def test_hls_concat_cmd_supports_mp4_fallback_extra_args(self):
|
||||
from apps.channels.tasks import _dvr_build_hls_concat_cmd
|
||||
|
||||
cmd = _dvr_build_hls_concat_cmd(
|
||||
"/data/concat.txt",
|
||||
"/data/intermediate.mp4",
|
||||
extra_args=["-bsf:a", "aac_adtstoasc"],
|
||||
)
|
||||
self.assertIn("aac_adtstoasc", cmd)
|
||||
self.assertEqual(cmd[-1], "/data/intermediate.mp4")
|
||||
|
||||
def test_run_recording_uses_hls_concat_helper(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
|
||||
source = inspect.getsource(run_recording)
|
||||
self.assertIn("_dvr_build_hls_concat_cmd", source)
|
||||
|
||||
# The concat subprocess.run call must include these flags
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", source,
|
||||
"Concat must use +genpts+igndts+discardcorrupt fflags")
|
||||
self.assertIn("ignore_err", source,
|
||||
"Concat must use -err_detect ignore_err")
|
||||
self.assertIn("-f", source)
|
||||
self.assertIn("concat", source)
|
||||
|
||||
def test_concat_goes_directly_to_mkv(self):
|
||||
"""Concat must produce MKV directly (not intermediate .ts) to
|
||||
preserve timestamp boundaries and avoid playback freeze at splice."""
|
||||
def test_recover_recordings_uses_hls_concat_helper(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
|
||||
# Must contain reset_timestamps for proper segment boundary handling
|
||||
self.assertIn("reset_timestamps", source,
|
||||
"Concat must use -reset_timestamps 1 for seamless seeking")
|
||||
# Must write directly to final_path (MKV), not an intermediate .ts
|
||||
self.assertIn("_concat_did_remux", source,
|
||||
"Concat path must set flag to skip separate remux step")
|
||||
|
||||
def test_segment_time_metadata_present(self):
|
||||
"""Verify concat uses -segment_time_metadata for boundary awareness."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
self.assertIn("segment_time_metadata", source,
|
||||
"Concat must use -segment_time_metadata 1 for segment boundary handling")
|
||||
source = inspect.getsource(recover_recordings_on_startup)
|
||||
self.assertIn("_dvr_build_hls_concat_cmd", source)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -486,6 +484,92 @@ class RecoverySkipListTests(TestCase):
|
|||
mock_run.apply_async.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 7. FFmpeg in-process retry loop
|
||||
# =========================================================================
|
||||
|
||||
class FfmpegRetryTests(TestCase):
|
||||
"""Verify FFmpeg restart logic for mid-recording crashes and stalls."""
|
||||
|
||||
def test_ffmpeg_retry_constants_and_helpers_exist(self):
|
||||
from apps.channels import tasks as dvr_tasks
|
||||
|
||||
self.assertGreater(dvr_tasks._dvr_ffmpeg_retry_window_seconds(), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_count_hls_segments(None), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_count_hls_segments("/nonexistent"), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(1), 0.25)
|
||||
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(12), 3.0)
|
||||
|
||||
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.stream_timeout", return_value=60)
|
||||
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.failover_grace_period", return_value=20)
|
||||
def test_retry_window_matches_live_proxy_timeouts(self, _grace, _stream):
|
||||
from apps.channels.tasks import _dvr_ffmpeg_retry_window_seconds
|
||||
|
||||
self.assertEqual(_dvr_ffmpeg_retry_window_seconds(), 80.0)
|
||||
|
||||
def test_hls_start_number_zero_when_playlist_exists(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m3u8 = os.path.join(tmp, "index.m3u8")
|
||||
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
|
||||
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
|
||||
with open(m3u8, "w") as f:
|
||||
f.write("#EXTM3U\n#EXT-X-TARGETDURATION:4\n")
|
||||
f.write("seg_00000.ts\nseg_00013.ts\n")
|
||||
# append_list reloads playlist entries; start_number must stay 0.
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, m3u8), 0)
|
||||
|
||||
def test_hls_start_number_from_max_index_without_playlist(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
|
||||
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 14)
|
||||
|
||||
def test_hls_start_number_zero_on_fresh_dir(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 0)
|
||||
|
||||
def test_build_ffmpeg_cmd_continues_hls_numbering(self):
|
||||
from apps.channels.tasks import _dvr_build_ffmpeg_cmd
|
||||
|
||||
cmd = _dvr_build_ffmpeg_cmd(
|
||||
"http://127.0.0.1:5656/proxy/ts/stream/uuid",
|
||||
71,
|
||||
"/data/recordings/.dvr_71_hls/index.m3u8",
|
||||
"/data/recordings/.dvr_71_hls/seg_%05d.ts",
|
||||
42,
|
||||
)
|
||||
self.assertIn("-start_number", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-start_number") + 1], "42")
|
||||
hls_flags = cmd[cmd.index("-hls_flags") + 1]
|
||||
self.assertIn("append_list", hls_flags)
|
||||
self.assertIn("omit_endlist", hls_flags)
|
||||
self.assertIn("-err_detect", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
|
||||
|
||||
def test_run_recording_has_retry_loop(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
|
||||
source = inspect.getsource(run_recording)
|
||||
self.assertIn("_ffmpeg_retry_count", source)
|
||||
self.assertIn("_ffmpeg_outage_started", source)
|
||||
self.assertIn("_ffmpeg_retry_window", source)
|
||||
self.assertIn("_break_reason", source)
|
||||
self.assertIn("ffmpeg_outage_window_exhausted", source)
|
||||
self.assertIn("_dvr_build_ffmpeg_cmd", source)
|
||||
self.assertIn("_dvr_hls_start_number", source)
|
||||
self.assertIn("_ffmpeg_retry_count = 0", source)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
|
||||
# =========================================================================
|
||||
|
|
|
|||
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Tests for DVR recording playback authentication (file/hls endpoints)."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.channels.api_views import _recording_auth_query_suffix
|
||||
from apps.channels.models import Channel, Recording
|
||||
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user, _ = User.objects.get_or_create(
|
||||
username="recording_playback_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
user.user_level = User.UserLevel.ADMIN
|
||||
user.set_password("pass")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=["testserver"])
|
||||
@patch("apps.channels.api_views.network_access_allowed", return_value=True)
|
||||
class RecordingPlaybackAuthTests(TestCase):
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=42, name="Playback Auth Channel")
|
||||
self.user = _make_admin()
|
||||
self.client = APIClient()
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".mkv", delete=False)
|
||||
self.tmp.write(b"\x00" * 1024)
|
||||
self.tmp.close()
|
||||
self.hls_dir = tempfile.mkdtemp(prefix="dvr_playback_auth_hls_")
|
||||
with open(os.path.join(self.hls_dir, "index.m3u8"), "w", encoding="utf-8") as playlist:
|
||||
playlist.write("#EXTM3U\n#EXTINF:4.0,\nseg_00001.ts\n")
|
||||
with open(os.path.join(self.hls_dir, "seg_00001.ts"), "wb") as segment:
|
||||
segment.write(b"\x00" * 188)
|
||||
now = timezone.now()
|
||||
self.recording = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "completed",
|
||||
"file_path": self.tmp.name,
|
||||
"file_name": "test.mkv",
|
||||
},
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.tmp.name):
|
||||
os.unlink(self.tmp.name)
|
||||
if os.path.isdir(self.hls_dir):
|
||||
shutil.rmtree(self.hls_dir, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def _jwt_for(user):
|
||||
return str(RefreshToken.for_user(user).access_token)
|
||||
|
||||
def test_file_requires_authentication(self, _mock_network):
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{self.recording.id}/file/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_file_accepts_jwt_query_param(self, _mock_network):
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{self.recording.id}/file/",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_file_redirect_to_hls_preserves_token(self, _mock_network):
|
||||
pending = os.path.join(self.hls_dir, "pending.mkv")
|
||||
now = timezone.now()
|
||||
in_progress = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
"file_path": pending,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{in_progress.id}/file/",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("token=", response["Location"])
|
||||
self.assertIn("/hls/index.m3u8", response["Location"])
|
||||
|
||||
def test_hls_playlist_rewrites_segments_with_token_when_present(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.content.decode("utf-8")
|
||||
self.assertIn("token=", body)
|
||||
self.assertIn("seg_00001.ts", body)
|
||||
|
||||
def test_hls_playlist_omits_token_when_not_in_request(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.content.decode("utf-8")
|
||||
self.assertIn("seg_00001.ts", body)
|
||||
self.assertNotIn("token=", body)
|
||||
|
||||
def test_hls_segment_accepts_jwt_query_param(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/seg_00001.ts",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_hls_redirect_to_file_preserves_token(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "completed",
|
||||
"file_path": self.tmp.name,
|
||||
"file_name": "test.mkv",
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("token=", response["Location"])
|
||||
self.assertIn("/file/", response["Location"])
|
||||
|
||||
|
||||
class RecordingAuthQuerySuffixTests(TestCase):
|
||||
def test_empty_when_no_token(self):
|
||||
request = SimpleNamespace(GET={})
|
||||
self.assertEqual(_recording_auth_query_suffix(request), "")
|
||||
|
||||
def test_includes_token_when_present(self):
|
||||
request = SimpleNamespace(GET={"token": "abc123"})
|
||||
self.assertEqual(_recording_auth_query_suffix(request), "?token=abc123")
|
||||
|
|
@ -282,8 +282,8 @@ class StopDvrClientsTests(TestCase):
|
|||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel")
|
||||
self._redis = "core.utils.RedisClient"
|
||||
self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client"
|
||||
self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel"
|
||||
self._sc = "apps.proxy.live_proxy.services.channel_service.ChannelService.stop_client"
|
||||
self._sch = "apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel"
|
||||
|
||||
def _mock_redis(self, client_ids, ua_map):
|
||||
r = MagicMock()
|
||||
|
|
|
|||
|
|
@ -716,3 +716,121 @@ class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase):
|
|||
prog = self._create_program(hours_from_now=2)
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(result["scheduled"], 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Title-only rule tests: tvg_id is empty / omitted (cross-EPG matching)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.channels.tasks.prefetch_recording_artwork")
|
||||
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
|
||||
class TitleOnlyRuleTests(SeriesRuleDedupBaseTestCase):
|
||||
"""Tests for series rules where tvg_id is omitted (searches all EPG channels)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_set_series_rules([{
|
||||
"tvg_id": "",
|
||||
"mode": "all",
|
||||
"title": "Test Show",
|
||||
}])
|
||||
|
||||
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.channels.tasks.release_task_lock")
|
||||
def test_title_only_rule_schedules_recording(self, mock_release, mock_lock,
|
||||
mock_schedule, mock_artwork):
|
||||
"""A rule with no tvg_id matches programs on any EPG channel by title."""
|
||||
from apps.channels.tasks import evaluate_series_rules_impl
|
||||
|
||||
self._create_program(hours_from_now=2)
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(result["scheduled"], 1)
|
||||
self.assertEqual(Recording.objects.count(), 1)
|
||||
self.assertEqual(Recording.objects.first().channel, self.channel)
|
||||
|
||||
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.channels.tasks.release_task_lock")
|
||||
def test_title_only_rule_matches_across_multiple_epg_channels(
|
||||
self, mock_release, mock_lock, mock_schedule, mock_artwork
|
||||
):
|
||||
"""Title-only rule creates a recording per matching EPG channel (distinct programs)."""
|
||||
from apps.channels.tasks import evaluate_series_rules_impl
|
||||
|
||||
epg2 = EPGData.objects.create(
|
||||
tvg_id="test.channel.2", name="Channel 2 EPG",
|
||||
epg_source=self.epg_source,
|
||||
)
|
||||
Channel.objects.create(
|
||||
channel_number=2, name="Test Channel 2", epg_data=epg2
|
||||
)
|
||||
|
||||
start1 = self.now + timedelta(hours=2)
|
||||
ProgramData.objects.create(
|
||||
epg=self.epg, tvg_id="test.channel.1",
|
||||
start_time=start1, end_time=start1 + timedelta(hours=1),
|
||||
title="Test Show", sub_title="Episode 1",
|
||||
)
|
||||
start2 = self.now + timedelta(hours=3)
|
||||
ProgramData.objects.create(
|
||||
epg=epg2, tvg_id="test.channel.2",
|
||||
start_time=start2, end_time=start2 + timedelta(hours=1),
|
||||
title="Test Show", sub_title="Episode 2",
|
||||
)
|
||||
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(result["scheduled"], 2)
|
||||
self.assertEqual(Recording.objects.count(), 2)
|
||||
|
||||
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.channels.tasks.release_task_lock")
|
||||
def test_title_only_rule_dedup_after_epg_refresh(self, mock_release, mock_lock,
|
||||
mock_schedule, mock_artwork):
|
||||
"""Dedup works for title-only rules after EPG refresh reassigns program IDs."""
|
||||
from apps.channels.tasks import evaluate_series_rules_impl
|
||||
|
||||
prog = self._create_program(hours_from_now=2)
|
||||
evaluate_series_rules_impl()
|
||||
self.assertEqual(Recording.objects.count(), 1)
|
||||
|
||||
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(Recording.objects.count(), 1)
|
||||
self.assertEqual(result["scheduled"], 0)
|
||||
|
||||
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.channels.tasks.release_task_lock")
|
||||
def test_invalid_rule_no_title_no_description_skipped(
|
||||
self, mock_release, mock_lock, mock_schedule, mock_artwork
|
||||
):
|
||||
"""Rules with neither title nor description are skipped and flagged invalid."""
|
||||
from apps.channels.tasks import evaluate_series_rules_impl
|
||||
|
||||
_set_series_rules([{"tvg_id": "", "mode": "all", "title": "", "description": ""}])
|
||||
self._create_program(hours_from_now=2)
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(result["scheduled"], 0)
|
||||
statuses = [d.get("status") for d in result["details"]]
|
||||
self.assertIn("invalid_rule", statuses)
|
||||
|
||||
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.channels.tasks.release_task_lock")
|
||||
def test_program_on_epg_with_no_channel_skipped(
|
||||
self, mock_release, mock_lock, mock_schedule, mock_artwork
|
||||
):
|
||||
"""Programs on an EPG source that has no Channel assigned are skipped gracefully."""
|
||||
from apps.channels.tasks import evaluate_series_rules_impl
|
||||
|
||||
epg_orphan = EPGData.objects.create(
|
||||
tvg_id="orphan.channel", name="Orphan EPG",
|
||||
epg_source=self.epg_source,
|
||||
)
|
||||
start = self.now + timedelta(hours=2)
|
||||
ProgramData.objects.create(
|
||||
epg=epg_orphan, tvg_id="orphan.channel",
|
||||
start_time=start, end_time=start + timedelta(hours=1),
|
||||
title="Test Show", sub_title="Episode 1",
|
||||
)
|
||||
|
||||
result = evaluate_series_rules_impl()
|
||||
self.assertEqual(result["scheduled"], 0)
|
||||
self.assertEqual(Recording.objects.count(), 0)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ from unittest.mock import MagicMock, patch, PropertyMock
|
|||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.ts_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.live_proxy.client_manager import ClientManager
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -141,7 +141,7 @@ class RemoveGhostClientsTests(TestCase):
|
|||
# Detailed stats path: exercises get_detailed_channel_info()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
@patch("apps.proxy.live_proxy.channel_status.ProxyServer")
|
||||
class DetailedStatsGhostClientTests(TestCase):
|
||||
"""get_detailed_channel_info() should remove ghost clients whose metadata
|
||||
hash has expired from the Redis client SET."""
|
||||
|
|
@ -162,7 +162,7 @@ class DetailedStatsGhostClientTests(TestCase):
|
|||
|
||||
def test_ghost_client_removed_from_set(self, mock_proxy_cls):
|
||||
"""Ghost client should be SREM'd and excluded from result."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.proxy.live_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
|
|
@ -181,7 +181,7 @@ class DetailedStatsGhostClientTests(TestCase):
|
|||
|
||||
def test_live_client_preserved(self, mock_proxy_cls):
|
||||
"""Client with valid metadata should appear in results."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.proxy.live_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
|
|
@ -204,7 +204,7 @@ class DetailedStatsGhostClientTests(TestCase):
|
|||
|
||||
def test_mixed_ghost_and_live(self, mock_proxy_cls):
|
||||
"""Only ghost clients should be removed; live ones preserved."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.proxy.live_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
|
|
@ -231,7 +231,7 @@ class DetailedStatsGhostClientTests(TestCase):
|
|||
# Basic stats path: exercises get_basic_channel_info()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
@patch("apps.proxy.live_proxy.channel_status.ProxyServer")
|
||||
class BasicStatsGhostClientTests(TestCase):
|
||||
"""get_basic_channel_info() should call remove_ghost_clients(), skip
|
||||
ghosts from display, and correct client_count."""
|
||||
|
|
@ -247,6 +247,14 @@ class BasicStatsGhostClientTests(TestCase):
|
|||
redis.scard.return_value = len(client_ids)
|
||||
redis.smembers.return_value = client_ids
|
||||
redis.hget.return_value = None # individual field lookups
|
||||
redis.hmget.return_value = [
|
||||
b'VLC/3.0',
|
||||
b'127.0.0.1',
|
||||
b'1773500000.0',
|
||||
None,
|
||||
b'mpegts',
|
||||
None,
|
||||
]
|
||||
|
||||
# Pipeline for remove_ghost_clients
|
||||
pipe = MagicMock()
|
||||
|
|
@ -260,7 +268,7 @@ class BasicStatsGhostClientTests(TestCase):
|
|||
|
||||
def test_ghost_removed_and_count_corrected(self, mock_proxy_cls):
|
||||
"""Ghost client should be cleaned and client_count decremented."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.proxy.live_proxy.channel_status import ChannelStatus
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls,
|
||||
|
|
@ -276,7 +284,7 @@ class BasicStatsGhostClientTests(TestCase):
|
|||
|
||||
def test_live_client_count_preserved(self, mock_proxy_cls):
|
||||
"""Live clients should be counted correctly."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.proxy.live_proxy.channel_status import ChannelStatus
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls,
|
||||
|
|
@ -295,7 +303,7 @@ class BasicStatsGhostClientTests(TestCase):
|
|||
# Orphaned channel cleanup: exercises _check_orphaned_metadata()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
@patch("apps.proxy.live_proxy.channel_status.ProxyServer")
|
||||
class OrphanedChannelGhostValidationTests(TestCase):
|
||||
"""_check_orphaned_metadata() should validate client SET entries when
|
||||
owner is dead and client_count > 0. If all clients are ghosts, it
|
||||
|
|
@ -334,7 +342,7 @@ class OrphanedChannelGhostValidationTests(TestCase):
|
|||
|
||||
def test_all_ghosts_triggers_cleanup(self, mock_proxy_cls):
|
||||
"""When all clients are ghosts, channel should be cleaned up."""
|
||||
from apps.proxy.ts_proxy.server import ProxyServer
|
||||
from apps.proxy.live_proxy.server import ProxyServer
|
||||
|
||||
channel_id = "00000000-0000-0000-0000-000000000005"
|
||||
server, redis = self._make_server_for_orphan_check(
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.ts_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.ts_proxy.stream_manager import StreamManager
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.live_proxy.input.manager import StreamManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -84,7 +84,7 @@ def _run_finally_block(sm, owner_value, current_state):
|
|||
redis.setex.reset_mock()
|
||||
|
||||
with patch.object(threading, 'Thread', return_value=MagicMock()):
|
||||
with patch('apps.proxy.ts_proxy.stream_manager.ConfigHelper') as mock_cfg:
|
||||
with patch('apps.proxy.live_proxy.input.manager.ConfigHelper') as mock_cfg:
|
||||
mock_cfg.max_stream_switches.return_value = 0
|
||||
mock_cfg.max_retries.return_value = sm.max_retries
|
||||
sm.run()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class OwnerWorkerKeepaliveTests(TestCase):
|
|||
"""Owner worker has a stream_manager; keepalive logic uses it directly."""
|
||||
|
||||
def _make_generator(self, healthy, at_buffer_head, consecutive_empty):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000001"
|
||||
gen.client_id = "test-client"
|
||||
|
|
@ -73,7 +73,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
"""Non-owner worker has stream_manager=None; health determined from Redis."""
|
||||
|
||||
def _make_generator(self, consecutive_empty=10):
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000002"
|
||||
gen.client_id = "test-client-nonowner"
|
||||
|
|
@ -108,7 +108,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
fresh_ts = str(time.time() - 2.0).encode()
|
||||
server = self._mock_proxy_server(fresh_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
stale_ts = str(time.time() - 12.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
ts = str(time.time() - 10.0).encode()
|
||||
server = self._mock_proxy_server(ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
gen = self._make_generator()
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
server = MagicMock()
|
||||
server.redis_client = None
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
"""Non-owner, Redis raises an exception -> conservative, no keepalive."""
|
||||
gen = self._make_generator()
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.side_effect = Exception("Redis error")
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
gen.buffer.index = 100 # far ahead of local_index=10
|
||||
server = self._mock_proxy_server(None)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
stale_ts = str(time.time() - 30.0).encode()
|
||||
server = self._mock_proxy_server(stale_ts)
|
||||
|
||||
with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS:
|
||||
with patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer") as MockPS:
|
||||
MockPS.get_instance.return_value = server
|
||||
result = gen._should_send_keepalive(gen.local_index)
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ class DoStatsUpdateTests(TestCase):
|
|||
"""_do_stats_update runs the actual Redis scan + WebSocket call."""
|
||||
|
||||
def _make_client_manager(self):
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
from apps.proxy.live_proxy.client_manager import ClientManager
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000004"
|
||||
cm._heartbeat_running = False
|
||||
|
|
@ -217,8 +217,8 @@ class DoStatsUpdateTests(TestCase):
|
|||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \
|
||||
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
mock_ws.assert_called_once()
|
||||
|
|
@ -231,25 +231,25 @@ class DoStatsUpdateTests(TestCase):
|
|||
"""Redis failure must be swallowed (logged), not propagated."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")):
|
||||
with patch("core.utils.RedisClient.get_client", side_effect=Exception("Redis down")):
|
||||
try:
|
||||
cm._do_stats_update()
|
||||
except Exception as e:
|
||||
self.fail(f"_do_stats_update raised an exception: {e}")
|
||||
|
||||
def test_do_stats_update_scans_channel_client_keys(self):
|
||||
"""Must scan for ts_proxy:channel:*:clients pattern."""
|
||||
def test_do_stats_update_scans_channel_metadata_keys(self):
|
||||
"""Must scan for live:channel:*:metadata pattern."""
|
||||
cm = self._make_client_manager()
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.scan.return_value = (0, [])
|
||||
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \
|
||||
patch("redis.Redis.from_url", return_value=mock_redis):
|
||||
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update"), \
|
||||
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
cm._do_stats_update()
|
||||
|
||||
scan_call = mock_redis.scan.call_args
|
||||
self.assertIn("ts_proxy:channel:*:clients", str(scan_call))
|
||||
self.assertIn("live:channel:*:metadata", str(scan_call))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -261,7 +261,7 @@ class ClientRemoveIntegrationTests(TestCase):
|
|||
|
||||
def test_remove_client_does_not_block_on_websocket(self):
|
||||
"""remove_client() must return quickly even if WebSocket is slow."""
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
from apps.proxy.live_proxy.client_manager import ClientManager
|
||||
|
||||
cm = ClientManager.__new__(ClientManager)
|
||||
cm.channel_id = "00000000-0000-0000-0000-000000000005"
|
||||
|
|
@ -269,7 +269,7 @@ class ClientRemoveIntegrationTests(TestCase):
|
|||
cm.clients = {"test-client-1"}
|
||||
cm.last_heartbeat_time = {"test-client-1": time.time()}
|
||||
cm.last_active_time = time.time()
|
||||
cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients"
|
||||
cm.client_set_key = f"live:channel:{cm.channel_id}:clients"
|
||||
cm.client_ttl = 60
|
||||
cm.worker_id = "worker-1"
|
||||
cm.proxy_server = MagicMock()
|
||||
|
|
@ -288,7 +288,7 @@ class ClientRemoveIntegrationTests(TestCase):
|
|||
slow_ws_called.set()
|
||||
|
||||
start = time.time()
|
||||
with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
|
||||
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update", side_effect=slow_websocket):
|
||||
cm.remove_client("test-client-1")
|
||||
elapsed = time.time() - start
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from django.test import TestCase
|
|||
|
||||
def _make_generator(consecutive_empty=10, local_index=10, buffer_index=10):
|
||||
"""Minimal StreamGenerator stub for testing _stream_data_generator logic."""
|
||||
from apps.proxy.ts_proxy.stream_generator import StreamGenerator
|
||||
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
||||
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "00000000-0000-0000-0000-000000000099"
|
||||
|
|
@ -65,11 +65,11 @@ class KeepaliveDurationCapTests(TestCase):
|
|||
patch.object(gen, '_should_send_keepalive', return_value=True), \
|
||||
patch.object(gen, '_is_ghost_client', return_value=False), \
|
||||
patch.object(gen, '_is_timeout', return_value=False), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.gevent') as mock_gevent, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.gevent') as mock_gevent, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.time') as mock_time:
|
||||
|
||||
MockPS.get_instance.return_value = None
|
||||
MockConfig.KEEPALIVE_INTERVAL = 0
|
||||
|
|
@ -105,11 +105,11 @@ class KeepaliveDurationCapTests(TestCase):
|
|||
patch.object(gen, '_should_send_keepalive', return_value=True), \
|
||||
patch.object(gen, '_is_ghost_client', return_value=False), \
|
||||
patch.object(gen, '_is_timeout', return_value=False), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.gevent'), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.time') as mock_time:
|
||||
|
||||
MockPS.get_instance.return_value = None
|
||||
MockConfig.KEEPALIVE_INTERVAL = 0
|
||||
|
|
@ -145,11 +145,11 @@ class KeepaliveDurationCapTests(TestCase):
|
|||
patch.object(gen, '_is_ghost_client', return_value=False), \
|
||||
patch.object(gen, '_is_timeout', return_value=False), \
|
||||
patch.object(gen, '_process_chunks', return_value=iter([chunk])), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.gevent'), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.time') as mock_time:
|
||||
|
||||
MockPS.get_instance.return_value = None
|
||||
MockConfig.KEEPALIVE_INTERVAL = 0
|
||||
|
|
@ -170,11 +170,11 @@ class KeepaliveDurationCapTests(TestCase):
|
|||
patch.object(gen, '_should_send_keepalive', return_value=True), \
|
||||
patch.object(gen, '_is_ghost_client', return_value=False), \
|
||||
patch.object(gen, '_is_timeout', return_value=False), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.gevent'), \
|
||||
patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time:
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.create_ts_packet', return_value=b'\x00' * 188), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.ProxyServer') as MockPS, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.Config') as MockConfig, \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.gevent'), \
|
||||
patch('apps.proxy.live_proxy.output.ts.generator.time') as mock_time:
|
||||
|
||||
MockPS.get_instance.return_value = None
|
||||
MockConfig.KEEPALIVE_INTERVAL = 0
|
||||
|
|
|
|||
984
apps/channels/tests/test_ts_proxy_teardown.py
Normal file
984
apps/channels/tests/test_ts_proxy_teardown.py
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
"""Tests for multi-worker channel teardown coordination."""
|
||||
import time
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.live_proxy.input.buffer import StreamBuffer
|
||||
from apps.proxy.live_proxy.input.manager import StreamManager
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.live_proxy.server import ProxyServer
|
||||
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
||||
|
||||
|
||||
CHANNEL_ID = "00000000-0000-0000-0000-000000000099"
|
||||
|
||||
|
||||
def _configure_ownership_pipeline(
|
||||
redis,
|
||||
*,
|
||||
stop_exists=0,
|
||||
metadata_exists=1,
|
||||
client_count=0,
|
||||
owner=None,
|
||||
disconnect=None,
|
||||
state=None,
|
||||
):
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
pipe.execute.return_value = (
|
||||
stop_exists,
|
||||
metadata_exists,
|
||||
client_count,
|
||||
owner,
|
||||
disconnect,
|
||||
state,
|
||||
)
|
||||
return pipe
|
||||
|
||||
|
||||
def _mock_proxy_server(redis_client=None):
|
||||
server = MagicMock()
|
||||
server.redis_client = redis_client or MagicMock()
|
||||
server._stopping_channels = set()
|
||||
return server
|
||||
|
||||
|
||||
class ChannelTeardownAvailabilityTests(TestCase):
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_teardown_active_when_stopping_key_exists(self, mock_get_instance):
|
||||
redis = MagicMock()
|
||||
redis.exists.side_effect = lambda key: key == RedisKeys.channel_stopping(CHANNEL_ID)
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_teardown_active_when_metadata_state_is_stopping(self, mock_get_instance):
|
||||
redis = MagicMock()
|
||||
redis.exists.return_value = False
|
||||
redis.hget.return_value = ChannelState.STOPPING.encode()
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_shutdown_pending_within_delay_window(self, mock_get_instance, mock_delay):
|
||||
mock_delay.return_value = 5
|
||||
redis = MagicMock()
|
||||
redis.exists.return_value = False
|
||||
redis.get.return_value = str(time.time() - 2).encode()
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertTrue(ChannelService.is_shutdown_pending(CHANNEL_ID))
|
||||
self.assertFalse(ChannelService.is_channel_unavailable_for_new_clients(CHANNEL_ID))
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_cancel_pending_shutdown_clears_disconnect_key(self, mock_get_instance, mock_delay):
|
||||
mock_delay.return_value = 30
|
||||
redis = MagicMock()
|
||||
redis.exists.side_effect = lambda key: "last_client_disconnect" in key
|
||||
redis.get.return_value = None
|
||||
redis.hget.return_value = ChannelState.ACTIVE.encode()
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertTrue(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
||||
redis.delete.assert_any_call(RedisKeys.last_client_disconnect(CHANNEL_ID))
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_cancel_pending_shutdown_skips_during_active_stop(self, mock_get_instance, mock_delay):
|
||||
mock_delay.return_value = 30
|
||||
redis = MagicMock()
|
||||
redis.exists.return_value = True
|
||||
server = _mock_proxy_server(redis)
|
||||
server._stopping_channels = {CHANNEL_ID}
|
||||
mock_get_instance.return_value = server
|
||||
|
||||
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
||||
redis.delete.assert_not_called()
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_cancel_pending_shutdown_skips_real_teardown_without_grace(self, mock_get_instance, mock_delay):
|
||||
mock_delay.return_value = 30
|
||||
redis = MagicMock()
|
||||
redis.exists.side_effect = lambda key: "stopping" in key
|
||||
redis.get.return_value = None
|
||||
redis.hget.return_value = ChannelState.STOPPING.encode()
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
||||
redis.hset.assert_not_called()
|
||||
redis.delete.assert_not_called()
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_shutdown_pending_expired_after_delay(self, mock_get_instance, mock_delay):
|
||||
mock_delay.return_value = 5
|
||||
redis = MagicMock()
|
||||
redis.exists.return_value = False
|
||||
redis.get.return_value = str(time.time() - 10).encode()
|
||||
mock_get_instance.return_value = _mock_proxy_server(redis)
|
||||
|
||||
self.assertFalse(ChannelService.is_shutdown_pending(CHANNEL_ID))
|
||||
|
||||
|
||||
class ClientManagerAddClientTests(TestCase):
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ChannelService.cancel_pending_shutdown", return_value=False)
|
||||
@patch("apps.proxy.live_proxy.client_manager.send_websocket_update")
|
||||
def test_add_client_stores_ip_and_user_agent_in_redis(self, _mock_ws, _mock_cancel):
|
||||
from apps.proxy.live_proxy.client_manager import ClientManager
|
||||
|
||||
redis = MagicMock()
|
||||
cm = ClientManager(CHANNEL_ID, redis_client=redis, worker_id="worker-1")
|
||||
cm.proxy_server = MagicMock()
|
||||
|
||||
result = cm.add_client(
|
||||
"client-1",
|
||||
"10.0.2.163",
|
||||
user_agent="VLC/3.0.21",
|
||||
)
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
mapping = redis.hset.call_args[1]["mapping"]
|
||||
self.assertEqual(mapping["ip_address"], "10.0.2.163")
|
||||
self.assertEqual(mapping["user_agent"], "VLC/3.0.21")
|
||||
|
||||
|
||||
class LocalStreamActivityTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server.stream_buffers = {}
|
||||
server.client_managers = {}
|
||||
server.profile_managers = {}
|
||||
server.profile_buffers = {}
|
||||
server._live_stream_managers = {}
|
||||
server._stopping_channels = set()
|
||||
server.redis_client = MagicMock()
|
||||
server.stop_all_output_formats = MagicMock()
|
||||
server.stop_all_output_profiles = MagicMock()
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_join_stream_thread")
|
||||
def test_stop_local_stream_activity_stops_live_manager(self, mock_join):
|
||||
server = self._make_server()
|
||||
manager = MagicMock()
|
||||
server._live_stream_managers[CHANNEL_ID] = manager
|
||||
|
||||
server._stop_local_stream_activity(CHANNEL_ID)
|
||||
|
||||
manager.stop.assert_called_once()
|
||||
mock_join.assert_called_once_with(CHANNEL_ID)
|
||||
self.assertNotIn(CHANNEL_ID, server._live_stream_managers)
|
||||
|
||||
|
||||
class OrphanMetadataCleanupTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server.stream_buffers = {}
|
||||
server.client_managers = {}
|
||||
server.profile_managers = {}
|
||||
server.profile_buffers = {}
|
||||
server._live_stream_managers = {}
|
||||
server._stopping_channels = set()
|
||||
server.redis_client = MagicMock()
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
||||
def test_orphan_metadata_stops_local_processes_before_redis(
|
||||
self, mock_has_upstream, mock_stop_local, mock_clean_redis
|
||||
):
|
||||
server = self._make_server()
|
||||
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
||||
server.redis_client.keys.return_value = [metadata_key.encode()]
|
||||
server.redis_client.hgetall.return_value = {
|
||||
b"owner": b"",
|
||||
b"state": b"unknown",
|
||||
}
|
||||
server.redis_client.exists.return_value = False
|
||||
server.redis_client.scard.return_value = 0
|
||||
|
||||
server._check_orphaned_metadata()
|
||||
|
||||
mock_has_upstream.assert_called_with(CHANNEL_ID)
|
||||
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
||||
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
||||
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
|
||||
def test_orphan_metadata_remote_channel_broadcasts_stop(
|
||||
self, mock_has_upstream, mock_stop_local, mock_broadcast, mock_clean_redis
|
||||
):
|
||||
server = self._make_server()
|
||||
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
||||
server.redis_client.keys.return_value = [metadata_key.encode()]
|
||||
server.redis_client.hgetall.return_value = {b"owner": b"", b"state": b"unknown"}
|
||||
server.redis_client.exists.return_value = False
|
||||
server.redis_client.scard.return_value = 0
|
||||
|
||||
server._check_orphaned_metadata()
|
||||
|
||||
mock_broadcast.assert_called_once_with(CHANNEL_ID)
|
||||
mock_stop_local.assert_not_called()
|
||||
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
||||
|
||||
|
||||
class OrphanChannelCleanupTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server.stream_buffers = {}
|
||||
server.client_managers = {}
|
||||
server.profile_managers = {}
|
||||
server.profile_buffers = {}
|
||||
server._live_stream_managers = {}
|
||||
server._stopping_channels = set()
|
||||
server.redis_client = MagicMock()
|
||||
server.get_channel_owner = MagicMock(return_value=None)
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
||||
def test_orphan_channel_stops_local_before_redis(
|
||||
self, mock_has_upstream, mock_stop_local, mock_clean_redis
|
||||
):
|
||||
server = self._make_server()
|
||||
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
||||
server.redis_client.keys.return_value = [metadata_key.encode()]
|
||||
server.redis_client.scard.return_value = 0
|
||||
|
||||
server._check_orphaned_channels()
|
||||
|
||||
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
||||
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
||||
|
||||
|
||||
class StreamManagerOwnershipTests(TestCase):
|
||||
def test_still_owner_false_when_different_worker(self):
|
||||
buffer = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
buffer.redis_client,
|
||||
owner=b"worker-b",
|
||||
)
|
||||
manager = StreamManager(
|
||||
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
||||
)
|
||||
|
||||
self.assertFalse(manager._still_owner())
|
||||
|
||||
def test_still_owner_true_when_owner_lock_expired_but_not_stopping(self):
|
||||
buffer = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
buffer.redis_client,
|
||||
client_count=0,
|
||||
owner=None,
|
||||
state=ChannelState.CONNECTING.encode(),
|
||||
)
|
||||
manager = StreamManager(
|
||||
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
||||
)
|
||||
|
||||
self.assertTrue(manager._still_owner())
|
||||
|
||||
def test_still_owner_false_when_channel_stopping_key_set(self):
|
||||
buffer = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
buffer.redis_client,
|
||||
stop_exists=1,
|
||||
)
|
||||
manager = StreamManager(
|
||||
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
||||
)
|
||||
|
||||
self.assertFalse(manager._still_owner())
|
||||
|
||||
def test_update_bytes_skipped_after_ownership_lost(self):
|
||||
buffer = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
buffer.redis_client,
|
||||
owner=b"other-worker",
|
||||
)
|
||||
manager = StreamManager(
|
||||
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
||||
)
|
||||
manager.bytes_processed = 1000
|
||||
|
||||
manager._update_bytes_processed(500)
|
||||
|
||||
buffer.redis_client.hincrby.assert_not_called()
|
||||
|
||||
|
||||
class StreamBufferStopTests(TestCase):
|
||||
def test_stop_discards_local_data_without_redis_writes(self):
|
||||
redis = MagicMock()
|
||||
buffer = StreamBuffer(channel_id=CHANNEL_ID, redis_client=redis)
|
||||
buffer._write_buffer = bytearray(b"x" * 376)
|
||||
|
||||
buffer.stop()
|
||||
|
||||
self.assertTrue(buffer.stopping)
|
||||
self.assertEqual(len(buffer._write_buffer), 0)
|
||||
redis.incr.assert_not_called()
|
||||
redis.setex.assert_not_called()
|
||||
redis.delete.assert_not_called()
|
||||
|
||||
|
||||
class StopChannelTeardownTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server.stream_buffers = {}
|
||||
server.client_managers = {}
|
||||
server.profile_managers = {}
|
||||
server.profile_buffers = {}
|
||||
server._live_stream_managers = {}
|
||||
server._stopping_channels = set()
|
||||
server._stopping_since = {}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.exists.return_value = False
|
||||
server.am_i_owner = MagicMock(return_value=False)
|
||||
server._collect_channel_stop_event_data = MagicMock(return_value=None)
|
||||
server.release_ownership = MagicMock()
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
def test_stop_channel_cleans_redis_before_blocking_local_stop(
|
||||
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
||||
):
|
||||
server = self._make_server()
|
||||
call_order = []
|
||||
|
||||
def stop_local(channel_id):
|
||||
call_order.append("local")
|
||||
|
||||
def clean_redis(channel_id):
|
||||
call_order.append("redis")
|
||||
|
||||
mock_stop_local.side_effect = stop_local
|
||||
mock_clean_redis.side_effect = clean_redis
|
||||
|
||||
server.stop_channel(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(call_order, ["redis", "local"])
|
||||
mock_spawn_event.assert_called_once_with(None)
|
||||
|
||||
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity", side_effect=RuntimeError("boom"))
|
||||
def test_stop_channel_cleans_redis_in_finally_when_local_stop_fails(
|
||||
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
||||
):
|
||||
server = self._make_server()
|
||||
|
||||
result = server.stop_channel(CHANNEL_ID)
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
||||
mock_spawn_event.assert_not_called()
|
||||
self.assertNotIn(CHANNEL_ID, server._stopping_channels)
|
||||
|
||||
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
||||
@patch.object(ProxyServer, "_clean_redis_keys")
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
def test_stop_channel_owner_releases_after_redis_cleanup(
|
||||
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
||||
):
|
||||
server = self._make_server()
|
||||
server.am_i_owner.return_value = True
|
||||
stop_data = {"channel_id": CHANNEL_ID}
|
||||
server._collect_channel_stop_event_data.return_value = stop_data
|
||||
call_order = []
|
||||
|
||||
def stop_local(channel_id):
|
||||
call_order.append("local")
|
||||
|
||||
def release(channel_id):
|
||||
call_order.append("release")
|
||||
|
||||
def clean_redis(channel_id):
|
||||
call_order.append("redis")
|
||||
|
||||
mock_stop_local.side_effect = stop_local
|
||||
server.release_ownership.side_effect = release
|
||||
mock_clean_redis.side_effect = clean_redis
|
||||
|
||||
server.stop_channel(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(call_order, ["redis", "release", "local"])
|
||||
server._collect_channel_stop_event_data.assert_called_once_with(CHANNEL_ID)
|
||||
mock_spawn_event.assert_called_once_with(stop_data)
|
||||
|
||||
|
||||
class CleanRedisKeysOrderTests(TestCase):
|
||||
@patch("apps.proxy.live_proxy.server.Stream.objects.get")
|
||||
@patch("apps.proxy.live_proxy.server.Channel.objects.get")
|
||||
def test_clean_redis_keys_releases_profile_slot_before_live_keys_deleted(
|
||||
self, mock_channel_get, mock_stream_get
|
||||
):
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.redis_client = MagicMock()
|
||||
call_order = []
|
||||
|
||||
channel = MagicMock()
|
||||
channel.release_stream.return_value = True
|
||||
|
||||
def channel_get(uuid):
|
||||
call_order.append("release")
|
||||
return channel
|
||||
|
||||
mock_channel_get.side_effect = channel_get
|
||||
mock_stream_get.side_effect = Stream.DoesNotExist
|
||||
|
||||
channel_key = f"live:channel:{CHANNEL_ID}:input:buffer:index".encode()
|
||||
|
||||
def scan(cursor, match=None, count=100):
|
||||
call_order.append("redis")
|
||||
if match == f"live:channel:{CHANNEL_ID}:*":
|
||||
return (0, [channel_key])
|
||||
return (0, [])
|
||||
|
||||
server.redis_client.scan.side_effect = scan
|
||||
|
||||
server._clean_redis_keys(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(call_order, ["release", "redis", "redis"])
|
||||
channel.release_stream.assert_called_once()
|
||||
server.redis_client.delete.assert_called_once_with(channel_key)
|
||||
|
||||
|
||||
class LocalUpstreamActivityTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server.stream_buffers = {}
|
||||
server.client_managers = {}
|
||||
server._live_stream_managers = {}
|
||||
return server
|
||||
|
||||
def test_upstream_activity_excludes_reader_only_client_manager(self):
|
||||
server = self._make_server()
|
||||
server.client_managers[CHANNEL_ID] = MagicMock()
|
||||
server.stream_buffers[CHANNEL_ID] = MagicMock()
|
||||
self.assertFalse(server._has_local_upstream_activity(CHANNEL_ID))
|
||||
|
||||
def test_upstream_activity_from_live_registry(self):
|
||||
server = self._make_server()
|
||||
server._live_stream_managers[CHANNEL_ID] = MagicMock()
|
||||
self.assertTrue(server._has_local_upstream_activity(CHANNEL_ID))
|
||||
|
||||
|
||||
class ClientDisconnectOwnershipTests(TestCase):
|
||||
def test_last_client_triggers_stop_when_upstream_active_without_owner_lock(self):
|
||||
from apps.proxy.live_proxy.client_manager import ClientManager
|
||||
|
||||
proxy_server = MagicMock()
|
||||
proxy_server.worker_id = "testhost:1"
|
||||
proxy_server.am_i_owner.return_value = False
|
||||
proxy_server._has_local_upstream_activity.return_value = True
|
||||
proxy_server.extend_ownership.return_value = False
|
||||
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = b"viewer"
|
||||
redis.scard.return_value = 0
|
||||
|
||||
manager = ClientManager(
|
||||
channel_id=CHANNEL_ID,
|
||||
redis_client=redis,
|
||||
worker_id="testhost:1",
|
||||
)
|
||||
manager.proxy_server = proxy_server
|
||||
manager.clients = {"client-1"}
|
||||
manager.client_set_key = RedisKeys.clients(CHANNEL_ID)
|
||||
manager._notify_owner_of_activity = MagicMock()
|
||||
manager._trigger_stats_update = MagicMock()
|
||||
manager.get_total_client_count = MagicMock(return_value=0)
|
||||
proxy_server._spawn_on_hub = MagicMock()
|
||||
|
||||
manager.remove_client("client-1")
|
||||
|
||||
proxy_server._spawn_on_hub.assert_called_once_with(
|
||||
proxy_server.handle_client_disconnect, CHANNEL_ID
|
||||
)
|
||||
|
||||
|
||||
class TeardownActiveLocalStopTests(TestCase):
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
||||
def test_teardown_active_when_local_stop_in_progress(self, mock_get_instance):
|
||||
server = MagicMock()
|
||||
server._stopping_channels = {CHANNEL_ID}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.exists.return_value = False
|
||||
mock_get_instance.return_value = server
|
||||
|
||||
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
||||
|
||||
|
||||
class HandleClientDisconnectUpstreamFirstTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.client_managers = {CHANNEL_ID: MagicMock()}
|
||||
server.stream_managers = {CHANNEL_ID: MagicMock()}
|
||||
server._live_stream_managers = {}
|
||||
server.profile_managers = {}
|
||||
server.output_managers = {}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.scard.return_value = 0
|
||||
server._stopping_channels = set()
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
||||
@patch.object(ProxyServer, "_stop_upstream_before_redis_cleanup")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
||||
def test_last_client_uses_coordinated_stop_only(
|
||||
self, _mock_upstream, mock_stop_upstream, mock_coordinated
|
||||
):
|
||||
server = self._make_server()
|
||||
|
||||
with patch(
|
||||
"apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay",
|
||||
return_value=0,
|
||||
):
|
||||
server.handle_client_disconnect(CHANNEL_ID)
|
||||
|
||||
mock_stop_upstream.assert_not_called()
|
||||
mock_coordinated.assert_called_once_with(CHANNEL_ID)
|
||||
|
||||
|
||||
class ShutdownDelayWaitTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
with patch.object(ProxyServer, "_start_cleanup_thread"):
|
||||
server = ProxyServer()
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.scard.return_value = 0
|
||||
return server
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
||||
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
def test_aborts_when_disconnect_key_deleted(self, _mock_delay, _mock_time, mock_sleep):
|
||||
server = self._make_server()
|
||||
server.redis_client.get.side_effect = [b"1000.0", None]
|
||||
|
||||
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertGreaterEqual(mock_sleep.call_count, 1)
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
||||
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
def test_aborts_when_clients_reconnect(self, _mock_delay, _mock_time, _mock_sleep):
|
||||
server = self._make_server()
|
||||
server.redis_client.get.return_value = b"1000.0"
|
||||
server.redis_client.scard.side_effect = [0, 1]
|
||||
|
||||
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
||||
|
||||
self.assertFalse(result)
|
||||
server.redis_client.delete.assert_called_with(
|
||||
RedisKeys.last_client_disconnect(CHANNEL_ID)
|
||||
)
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
||||
@patch("apps.proxy.live_proxy.server.time.time")
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
def test_timer_resets_when_disconnect_timestamp_updated(
|
||||
self, _mock_delay, mock_time, mock_sleep
|
||||
):
|
||||
server = self._make_server()
|
||||
disconnect_key = RedisKeys.last_client_disconnect(CHANNEL_ID)
|
||||
current_time = [1000.0]
|
||||
disconnect_timestamp = [1000.0]
|
||||
poll_count = [0]
|
||||
|
||||
mock_time.side_effect = lambda: current_time[0]
|
||||
|
||||
def get_side_effect(key):
|
||||
poll_count[0] += 1
|
||||
if poll_count[0] >= 3:
|
||||
disconnect_timestamp[0] = 1020.0
|
||||
if key == disconnect_key:
|
||||
return str(disconnect_timestamp[0]).encode()
|
||||
return None
|
||||
|
||||
server.redis_client.get.side_effect = get_side_effect
|
||||
|
||||
def advance_sleep(duration):
|
||||
current_time[0] += duration
|
||||
|
||||
mock_sleep.side_effect = advance_sleep
|
||||
|
||||
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertGreaterEqual(current_time[0], 1050.0)
|
||||
|
||||
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
||||
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=True)
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
def test_handle_client_disconnect_uses_polling_wait(
|
||||
self, _mock_delay, mock_wait, mock_coordinated
|
||||
):
|
||||
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
|
||||
server.redis_client.get.return_value = b"1700000000.0"
|
||||
|
||||
server.handle_client_disconnect(CHANNEL_ID)
|
||||
|
||||
mock_wait.assert_called_once_with(CHANNEL_ID)
|
||||
mock_coordinated.assert_called_once_with(CHANNEL_ID)
|
||||
|
||||
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
||||
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=False)
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
def test_handle_client_disconnect_skips_stop_when_wait_aborted(
|
||||
self, _mock_delay, _mock_wait, mock_coordinated
|
||||
):
|
||||
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
|
||||
server.redis_client.get.return_value = b"1700000000.0"
|
||||
|
||||
server.handle_client_disconnect(CHANNEL_ID)
|
||||
|
||||
mock_coordinated.assert_not_called()
|
||||
|
||||
|
||||
class InitWaitAbortTests(TestCase):
|
||||
def _make_generator(self):
|
||||
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
||||
|
||||
return StreamGenerator(
|
||||
CHANNEL_ID,
|
||||
"client-1",
|
||||
"127.0.0.1",
|
||||
"test-agent",
|
||||
channel_initializing=True,
|
||||
)
|
||||
|
||||
def test_abort_when_client_removed_locally(self):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = set()
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
server.redis_client = MagicMock()
|
||||
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, time.time()), "client_gone")
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=10)
|
||||
def test_abort_when_connect_stalled_without_buffer(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = {"client-1"}
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
buffer = MagicMock()
|
||||
buffer.index = 0
|
||||
server.stream_buffers = {CHANNEL_ID: buffer}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - 11
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
||||
def test_no_stall_abort_within_init_grace_period(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = {"client-1"}
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
buffer = MagicMock()
|
||||
buffer.index = 0
|
||||
server.stream_buffers = {CHANNEL_ID: buffer}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - 15
|
||||
self.assertIsNone(generator._init_wait_abort_reason(server, started))
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
||||
def test_stall_abort_after_init_grace_period(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = {"client-1"}
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
buffer = MagicMock()
|
||||
buffer.index = 0
|
||||
server.stream_buffers = {CHANNEL_ID: buffer}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - 31
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
||||
|
||||
|
||||
class PromoteChannelWhenBufferReadyTests(TestCase):
|
||||
def _mock_proxy(self, redis_client):
|
||||
proxy_server = MagicMock()
|
||||
proxy_server.redis_client = redis_client
|
||||
return patch(
|
||||
"apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance",
|
||||
return_value=proxy_server,
|
||||
), proxy_server
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_ready_with_clients_becomes_active(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"4"
|
||||
redis.scard.return_value = 2
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.ACTIVE)
|
||||
proxy_server.update_channel_state.assert_called_once()
|
||||
args = proxy_server.update_channel_state.call_args[0]
|
||||
self.assertEqual(args[1], ChannelState.ACTIVE)
|
||||
self.assertEqual(args[2]["clients_at_activation"], "2")
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_ready_without_clients_becomes_waiting(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"5"
|
||||
redis.scard.return_value = 0
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.WAITING_FOR_CLIENTS)
|
||||
proxy_server.update_channel_state.assert_called_once_with(
|
||||
CHANNEL_ID,
|
||||
ChannelState.WAITING_FOR_CLIENTS,
|
||||
{
|
||||
ChannelMetadataField.CONNECTION_READY_TIME: ANY,
|
||||
ChannelMetadataField.BUFFER_CHUNKS: "5",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_not_ready_does_not_promote(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"2"
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertIsNone(result)
|
||||
proxy_server.update_channel_state.assert_not_called()
|
||||
|
||||
def test_waiting_for_clients_with_clients_becomes_active(self):
|
||||
redis = MagicMock()
|
||||
|
||||
def hget_side_effect(key, field):
|
||||
if field == ChannelMetadataField.STATE:
|
||||
return ChannelState.WAITING_FOR_CLIENTS.encode()
|
||||
if field == ChannelMetadataField.CONNECTION_READY_TIME:
|
||||
return b"1700000000.0"
|
||||
return None
|
||||
|
||||
redis.hget.side_effect = hget_side_effect
|
||||
redis.scard.return_value = 1
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.ACTIVE)
|
||||
proxy_server.update_channel_state.assert_called_once_with(
|
||||
CHANNEL_ID,
|
||||
ChannelState.ACTIVE,
|
||||
{"clients_at_activation": "1"},
|
||||
)
|
||||
|
||||
|
||||
class UpstreamStopBroadcastTests(TestCase):
|
||||
def _make_server(self):
|
||||
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
||||
server = ProxyServer()
|
||||
server.worker_id = "testhost:1"
|
||||
server.stream_managers = {}
|
||||
server._live_stream_managers = {}
|
||||
server.redis_client = MagicMock()
|
||||
return server
|
||||
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
||||
def test_local_upstream_stops_locally_without_broadcast(
|
||||
self, _mock_has, mock_broadcast, mock_stop_local
|
||||
):
|
||||
server = self._make_server()
|
||||
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
|
||||
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
||||
mock_broadcast.assert_not_called()
|
||||
|
||||
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
||||
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
||||
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
|
||||
def test_orphan_cleanup_broadcasts_when_no_local_upstream(
|
||||
self, _mock_has, mock_broadcast, mock_stop_local
|
||||
):
|
||||
server = self._make_server()
|
||||
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
|
||||
mock_broadcast.assert_called_once_with(CHANNEL_ID)
|
||||
mock_stop_local.assert_not_called()
|
||||
|
||||
|
||||
class StreamManagerStillOwnerTests(TestCase):
|
||||
def _make_manager(self, redis_client):
|
||||
buffer = MagicMock()
|
||||
buffer.redis_client = redis_client
|
||||
buffer.channel_id = CHANNEL_ID
|
||||
manager = StreamManager(
|
||||
CHANNEL_ID,
|
||||
"http://example/stream.ts",
|
||||
buffer,
|
||||
worker_id="testhost:1",
|
||||
)
|
||||
return manager
|
||||
|
||||
def test_stops_when_metadata_removed(self):
|
||||
redis = MagicMock()
|
||||
_configure_ownership_pipeline(redis, metadata_exists=0)
|
||||
manager = self._make_manager(redis)
|
||||
self.assertFalse(manager._still_owner())
|
||||
|
||||
def test_keeps_running_during_connecting_before_client_registered(self):
|
||||
redis = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
redis,
|
||||
client_count=0,
|
||||
owner=b"testhost:1",
|
||||
state=ChannelState.CONNECTING.encode(),
|
||||
)
|
||||
manager = self._make_manager(redis)
|
||||
self.assertTrue(manager._still_owner())
|
||||
|
||||
def test_stops_after_disconnect_when_shutdown_delay_is_zero(self):
|
||||
redis = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
redis,
|
||||
client_count=0,
|
||||
owner=b"testhost:1",
|
||||
disconnect=b"1700000000.0",
|
||||
state=ChannelState.ACTIVE.encode(),
|
||||
)
|
||||
manager = self._make_manager(redis)
|
||||
with patch(
|
||||
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
|
||||
return_value=0,
|
||||
):
|
||||
self.assertFalse(manager._still_owner())
|
||||
|
||||
def test_keeps_running_during_shutdown_delay(self):
|
||||
redis = MagicMock()
|
||||
_configure_ownership_pipeline(
|
||||
redis,
|
||||
client_count=0,
|
||||
owner=b"testhost:1",
|
||||
disconnect=str(time.time()).encode(),
|
||||
state=ChannelState.ACTIVE.encode(),
|
||||
)
|
||||
manager = self._make_manager(redis)
|
||||
with patch(
|
||||
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
|
||||
return_value=5,
|
||||
):
|
||||
self.assertTrue(manager._still_owner())
|
||||
|
||||
|
||||
class PreActiveNoClientsTimeoutTests(TestCase):
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
||||
def test_buffer_ready_uses_client_wait_period(self, _mock_client_wait):
|
||||
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
||||
connection_ready_time=1000.0,
|
||||
start_time=900.0,
|
||||
now=1006.0,
|
||||
)
|
||||
self.assertTrue(should_stop)
|
||||
self.assertEqual(timeout, 5)
|
||||
self.assertEqual(reason, "client_wait")
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
||||
def test_buffer_ready_within_client_wait_period(self, _mock_client_wait):
|
||||
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
||||
connection_ready_time=1000.0,
|
||||
start_time=900.0,
|
||||
now=1003.0,
|
||||
)
|
||||
self.assertFalse(should_stop)
|
||||
self.assertEqual(timeout, 5)
|
||||
self.assertEqual(reason, "client_wait")
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
|
||||
def test_startup_uses_init_grace_period(self, _mock_init_grace):
|
||||
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
||||
connection_ready_time=None,
|
||||
start_time=1000.0,
|
||||
now=1070.0,
|
||||
)
|
||||
self.assertTrue(should_stop)
|
||||
self.assertEqual(timeout, 60)
|
||||
self.assertEqual(reason, "startup")
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
|
||||
def test_startup_within_init_grace_period(self, _mock_init_grace):
|
||||
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
||||
connection_ready_time=None,
|
||||
start_time=1000.0,
|
||||
now=1030.0,
|
||||
)
|
||||
self.assertFalse(should_stop)
|
||||
self.assertEqual(timeout, 60)
|
||||
self.assertEqual(reason, "startup")
|
||||
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
||||
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
||||
def test_buffer_ready_does_not_use_shutdown_delay(self, mock_client_wait, mock_shutdown_delay):
|
||||
should_stop, _, reason = ProxyServer._pre_active_no_clients_should_stop(
|
||||
connection_ready_time=1000.0,
|
||||
start_time=900.0,
|
||||
now=1006.0,
|
||||
)
|
||||
self.assertTrue(should_stop)
|
||||
self.assertEqual(reason, "client_wait")
|
||||
mock_client_wait.assert_called_once()
|
||||
mock_shutdown_delay.assert_not_called()
|
||||
|
|
@ -1,9 +1,28 @@
|
|||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bound memory/DB work per chunk for large libraries (20k+ channels).
|
||||
EPG_LOGO_APPLY_BATCH_SIZE = 500
|
||||
EPG_LOGO_APPLY_MAX_ERRORS = 100
|
||||
|
||||
lock = threading.Lock()
|
||||
# Dictionary to track usage: {account_id: current_usage}
|
||||
active_streams_map = {}
|
||||
|
||||
|
||||
def format_channel_number(value, empty=""):
|
||||
"""Display formatting for an effective channel_number. Returns int for
|
||||
whole-valued floats (so ``123.0`` renders as ``123``), the float as-is
|
||||
for fractional values, or ``empty`` when the value is ``None``.
|
||||
"""
|
||||
if value is None:
|
||||
return empty
|
||||
if value == int(value):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
def increment_stream_count(account):
|
||||
with lock:
|
||||
current_usage = active_streams_map.get(account.id, 0)
|
||||
|
|
@ -23,3 +42,186 @@ def decrement_stream_count(account):
|
|||
active_streams_map[account.id] = current_usage
|
||||
account.active_streams = current_usage
|
||||
account.save(update_fields=['active_streams'])
|
||||
|
||||
|
||||
def auto_apply_epg_logos_enabled(custom_properties):
|
||||
"""Return whether channel logos should be auto-applied after EPG refresh."""
|
||||
return bool((custom_properties or {}).get('auto_apply_epg_logos', False))
|
||||
|
||||
|
||||
def _empty_logo_apply_stats():
|
||||
return {
|
||||
'updated_count': 0,
|
||||
'created_logos_count': 0,
|
||||
'error_count': 0,
|
||||
'errors': [],
|
||||
}
|
||||
|
||||
|
||||
def _merge_logo_apply_stats(accumulated, batch_stats):
|
||||
accumulated['updated_count'] += batch_stats['updated_count']
|
||||
accumulated['created_logos_count'] += batch_stats['created_logos_count']
|
||||
accumulated['error_count'] += batch_stats['error_count']
|
||||
remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(accumulated['errors'])
|
||||
if remaining > 0:
|
||||
accumulated['errors'].extend(batch_stats['errors'][:remaining])
|
||||
return accumulated
|
||||
|
||||
|
||||
def apply_logos_from_epg_icon_url(channels):
|
||||
"""
|
||||
Set channel.logo from epg_data.icon_url for the given channels.
|
||||
|
||||
Expects channels to be pre-filtered with select_related('epg_data', 'logo').
|
||||
Uses bulk logo lookup/create and a single channel bulk_update for efficiency.
|
||||
"""
|
||||
from .models import Channel, Logo
|
||||
|
||||
work = []
|
||||
url_to_meta = {}
|
||||
|
||||
for channel in channels:
|
||||
if not channel.epg_data:
|
||||
continue
|
||||
icon_url = (channel.epg_data.icon_url or '').strip()
|
||||
if not icon_url:
|
||||
continue
|
||||
if channel.logo and channel.logo.url == icon_url:
|
||||
continue
|
||||
work.append((channel, icon_url))
|
||||
if icon_url not in url_to_meta:
|
||||
url_to_meta[icon_url] = (
|
||||
channel.epg_data.name,
|
||||
channel.epg_data.tvg_id,
|
||||
)
|
||||
|
||||
if not work:
|
||||
return _empty_logo_apply_stats()
|
||||
|
||||
unique_urls = list(url_to_meta.keys())
|
||||
logo_by_url = {
|
||||
logo.url: logo
|
||||
for logo in Logo.objects.filter(url__in=unique_urls)
|
||||
}
|
||||
|
||||
missing_urls = [url for url in unique_urls if url not in logo_by_url]
|
||||
created_logos_count = 0
|
||||
if missing_urls:
|
||||
logos_to_create = [
|
||||
Logo(
|
||||
name=(url_to_meta[url][0] or f"Logo for {url_to_meta[url][1]}"),
|
||||
url=url,
|
||||
)
|
||||
for url in missing_urls
|
||||
]
|
||||
created_logos_count = len(logos_to_create)
|
||||
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
|
||||
for logo in Logo.objects.filter(url__in=unique_urls):
|
||||
logo_by_url[logo.url] = logo
|
||||
|
||||
channels_to_update = []
|
||||
errors = []
|
||||
for channel, icon_url in work:
|
||||
logo = logo_by_url.get(icon_url)
|
||||
if not logo:
|
||||
errors.append(f"Channel {channel.id}: Logo not found for {icon_url}")
|
||||
continue
|
||||
if channel.logo_id != logo.id:
|
||||
channel.logo = logo
|
||||
channels_to_update.append(channel)
|
||||
|
||||
if channels_to_update:
|
||||
Channel.objects.bulk_update(channels_to_update, ['logo'], batch_size=500)
|
||||
|
||||
return {
|
||||
'updated_count': len(channels_to_update),
|
||||
'created_logos_count': created_logos_count,
|
||||
'error_count': len(errors),
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
def channels_with_epg_icon_queryset(*, epg_source=None, epg_source_id=None):
|
||||
"""Channels mapped to a source that have a non-empty EPG icon URL."""
|
||||
from .models import Channel
|
||||
|
||||
qs = Channel.objects.filter(epg_data__isnull=False)
|
||||
if epg_source is not None:
|
||||
qs = qs.filter(epg_data__epg_source=epg_source)
|
||||
elif epg_source_id is not None:
|
||||
qs = qs.filter(epg_data__epg_source_id=epg_source_id)
|
||||
else:
|
||||
raise ValueError("epg_source or epg_source_id is required")
|
||||
|
||||
return qs.exclude(
|
||||
epg_data__icon_url__isnull=True,
|
||||
).exclude(
|
||||
epg_data__icon_url='',
|
||||
)
|
||||
|
||||
|
||||
def apply_logos_from_epg_queryset(channels_qs, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
|
||||
"""
|
||||
Apply logos for a potentially large queryset without loading every row at once.
|
||||
Streams channel IDs from the database and processes fixed-size chunks.
|
||||
"""
|
||||
from .models import Channel
|
||||
|
||||
stats = _empty_logo_apply_stats()
|
||||
batch_ids = []
|
||||
|
||||
id_stream = channels_qs.order_by('id').values_list('id', flat=True).iterator(
|
||||
chunk_size=batch_size,
|
||||
)
|
||||
for channel_id in id_stream:
|
||||
batch_ids.append(channel_id)
|
||||
if len(batch_ids) >= batch_size:
|
||||
batch = Channel.objects.filter(
|
||||
id__in=batch_ids,
|
||||
).select_related('epg_data', 'logo')
|
||||
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
|
||||
batch_ids = []
|
||||
|
||||
if batch_ids:
|
||||
batch = Channel.objects.filter(
|
||||
id__in=batch_ids,
|
||||
).select_related('epg_data', 'logo')
|
||||
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def apply_logos_from_epg_for_source(epg_source, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
|
||||
"""Apply EPG icon URLs to all channels mapped to the given EPG source."""
|
||||
channels_qs = channels_with_epg_icon_queryset(epg_source=epg_source)
|
||||
return apply_logos_from_epg_queryset(channels_qs, batch_size=batch_size)
|
||||
|
||||
|
||||
def maybe_auto_apply_epg_logos(epg_source):
|
||||
"""Auto-apply logos after refresh when enabled on the source. Non-fatal on error."""
|
||||
if not auto_apply_epg_logos_enabled(epg_source.custom_properties):
|
||||
return None
|
||||
try:
|
||||
stats = apply_logos_from_epg_for_source(epg_source)
|
||||
if stats['updated_count'] or stats['created_logos_count']:
|
||||
logger.info(
|
||||
"Auto-applied EPG logos for source %s: updated %s channels, "
|
||||
"created %s logos.",
|
||||
epg_source.name,
|
||||
stats['updated_count'],
|
||||
stats['created_logos_count'],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto-apply EPG logos for source %s: all matched channels already current.",
|
||||
epg_source.name,
|
||||
)
|
||||
return stats
|
||||
except Exception as logo_error:
|
||||
logger.warning(
|
||||
"EPG logo auto-apply failed for source %s (non-fatal): %s",
|
||||
epg_source.name,
|
||||
logo_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
from .serializers import (
|
||||
IntegrationSerializer,
|
||||
|
|
@ -37,6 +38,33 @@ class IntegrationViewSet(viewsets.ModelViewSet):
|
|||
serializer = EventSubscriptionSerializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
methods=["PUT"],
|
||||
description=(
|
||||
"Replace the integration's event subscriptions with the provided list. "
|
||||
"Accepts a JSON array of subscription objects. "
|
||||
"Existing subscriptions not in the list will be deleted. "
|
||||
"The 'payload_template' field is only relevant for webhook integrations."
|
||||
),
|
||||
request=inline_serializer(
|
||||
name="SetSubscriptionsRequest",
|
||||
fields={
|
||||
"event": serializers.CharField(help_text="Event name (e.g. 'channel_start')."),
|
||||
"enabled": serializers.BooleanField(required=False, default=True),
|
||||
"payload_template": serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="Custom payload template (webhook integrations only)."),
|
||||
},
|
||||
many=True,
|
||||
),
|
||||
responses={200: inline_serializer(
|
||||
name="SetSubscriptionsResponse",
|
||||
fields={
|
||||
"event": serializers.CharField(),
|
||||
"enabled": serializers.BooleanField(),
|
||||
"payload_template": serializers.CharField(allow_null=True),
|
||||
},
|
||||
many=True,
|
||||
)},
|
||||
)
|
||||
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
|
||||
def set_subscriptions(self, request, pk=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,89 @@
|
|||
# connect/handlers/script.py
|
||||
import os
|
||||
import select as _select
|
||||
import signal as _signal
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from django.conf import settings
|
||||
from .base import IntegrationHandler
|
||||
|
||||
|
||||
def _waitpid_nonblocking(pid, timeout_s=2.0):
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
wpid, status = os.waitpid(pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
return -1
|
||||
if wpid == pid:
|
||||
return os.WEXITSTATUS(status) if os.WIFEXITED(status) else -os.WTERMSIG(status)
|
||||
time.sleep(0.01)
|
||||
return -1
|
||||
|
||||
|
||||
def _posix_run(path, env, timeout):
|
||||
stdout_r, stdout_w = os.pipe()
|
||||
stderr_r, stderr_w = os.pipe()
|
||||
devnull_r = os.open(os.devnull, os.O_RDONLY)
|
||||
file_actions = [
|
||||
(os.POSIX_SPAWN_DUP2, devnull_r, 0),
|
||||
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
|
||||
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
|
||||
(os.POSIX_SPAWN_CLOSE, devnull_r),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stderr_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_r),
|
||||
(os.POSIX_SPAWN_CLOSE, stderr_r),
|
||||
]
|
||||
try:
|
||||
pid = os.posix_spawn(path, [path], env, file_actions=file_actions)
|
||||
except Exception:
|
||||
for fd in (stdout_r, stdout_w, stderr_r, stderr_w, devnull_r):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
for fd in (devnull_r, stdout_w, stderr_w):
|
||||
os.close(fd)
|
||||
|
||||
out, err = [], []
|
||||
done = set()
|
||||
deadline = time.monotonic() + timeout
|
||||
timed_out = False
|
||||
|
||||
try:
|
||||
while len(done) < 2:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
timed_out = True
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
break
|
||||
|
||||
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
|
||||
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
|
||||
for fd in readable:
|
||||
data = os.read(fd, 8192)
|
||||
if data:
|
||||
(out if fd == stdout_r else err).append(data)
|
||||
else:
|
||||
done.add(fd)
|
||||
finally:
|
||||
for fd in (stdout_r, stderr_r):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
rc = _waitpid_nonblocking(pid)
|
||||
if timed_out:
|
||||
raise TimeoutError(f"Script exceeded {timeout}s")
|
||||
return rc, b"".join(out).decode("utf-8", errors="replace"), b"".join(err).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _is_path_allowed(real_path: str) -> bool:
|
||||
# Ensure path is within one of the allowed directories
|
||||
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
||||
|
|
@ -56,26 +134,17 @@ class ScriptHandler(IntegrationHandler):
|
|||
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
||||
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
||||
|
||||
result = subprocess.run(
|
||||
[real_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
cwd=os.path.dirname(real_path) or None,
|
||||
)
|
||||
rc, stdout, stderr = _posix_run(real_path, env, timeout)
|
||||
|
||||
# Truncate outputs to avoid excessive memory/logging
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if len(stdout) > max_out:
|
||||
stdout = stdout[:max_out] + "... [truncated]"
|
||||
if len(stderr) > max_out:
|
||||
stderr = stderr[:max_out] + "... [truncated]"
|
||||
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"exit_code": rc,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"success": result.returncode == 0,
|
||||
"success": rc == 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 6.0.4 on 2026-04-23 23:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_connect', '0002_alter_eventsubscription_event'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventsubscription',
|
||||
name='event',
|
||||
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], max_length=100),
|
||||
),
|
||||
]
|
||||
|
|
@ -16,6 +16,8 @@ SUPPORTED_EVENTS = {
|
|||
"login_failed": "Login Failed",
|
||||
"epg_blocked": "EPG Blocked",
|
||||
"m3u_blocked": "M3U Blocked",
|
||||
"vod_start": "VOD Started",
|
||||
"vod_stop": "VOD Stopped",
|
||||
}
|
||||
|
||||
class Integration(models.Model):
|
||||
|
|
|
|||
157
apps/connect/tests/test_trigger_event.py
Normal file
157
apps/connect/tests/test_trigger_event.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
Regression tests for the plugin event dispatch loop in apps.connect.utils.
|
||||
|
||||
Previously, trigger_event accessed `plugin.key` / `plugin.name` (attribute
|
||||
access) on dict items returned by PluginManager.list_plugins(). On the
|
||||
first disabled plugin encountered, that f-string raised AttributeError —
|
||||
and because Python evaluates f-string arguments eagerly even when the
|
||||
logger discards the message at INFO level, the exception bubbled out of
|
||||
trigger_event with no try/except. Any enabled plugin sorted after a
|
||||
disabled one then silently received zero events.
|
||||
|
||||
These tests guard against regression by:
|
||||
|
||||
1. Feeding trigger_event a plugins list with a disabled plugin BEFORE an
|
||||
enabled-with-events plugin and asserting the enabled plugin's action
|
||||
is still dispatched.
|
||||
2. Sanity-checking that actions without a matching `events` entry are
|
||||
not dispatched.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
def _empty_subscription_chain():
|
||||
"""Mock the EventSubscription.objects.filter(...).select_related(...)
|
||||
chain to yield no subscriptions, so trigger_event proceeds straight to
|
||||
the plugin loop."""
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
empty_qs.__iter__ = lambda self: iter([])
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value = empty_qs
|
||||
return chain
|
||||
|
||||
|
||||
class TriggerEventDispatchTests(SimpleTestCase):
|
||||
def _run_trigger_event(self, handlers, event_name, payload, enabled_keys=None):
|
||||
pm = MagicMock()
|
||||
pm.iter_actions_for_event.return_value = handlers
|
||||
if enabled_keys is None:
|
||||
enabled_keys = [key for key, _ in handlers]
|
||||
|
||||
enabled_qs = MagicMock()
|
||||
enabled_qs.values_list.return_value = enabled_keys
|
||||
|
||||
with patch(
|
||||
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||
), patch(
|
||||
"apps.connect.utils.EventSubscription.objects.filter",
|
||||
return_value=_empty_subscription_chain(),
|
||||
), patch(
|
||||
"apps.plugins.models.PluginConfig"
|
||||
) as mock_cfg:
|
||||
mock_cfg.objects.filter.return_value = enabled_qs
|
||||
from apps.connect.utils import trigger_event
|
||||
|
||||
trigger_event(event_name, payload)
|
||||
return pm
|
||||
|
||||
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
|
||||
"""Enabled handlers still run when other plugins are disabled in DB."""
|
||||
handlers = [
|
||||
("enabled-plugin", "on_event"),
|
||||
]
|
||||
|
||||
pm = self._run_trigger_event(
|
||||
handlers, "channel_start", {"channel_name": "TEST"}
|
||||
)
|
||||
|
||||
pm.run_action.assert_called_once_with(
|
||||
"enabled-plugin",
|
||||
"on_event",
|
||||
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||
)
|
||||
|
||||
def test_skips_handlers_for_disabled_plugins(self):
|
||||
handlers = [
|
||||
("disabled-plugin", "on_event"),
|
||||
("enabled-plugin", "on_event"),
|
||||
]
|
||||
|
||||
pm = self._run_trigger_event(
|
||||
handlers,
|
||||
"channel_start",
|
||||
{"channel_name": "TEST"},
|
||||
enabled_keys=["enabled-plugin"],
|
||||
)
|
||||
|
||||
pm.run_action.assert_called_once_with(
|
||||
"enabled-plugin",
|
||||
"on_event",
|
||||
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||
)
|
||||
|
||||
def test_action_without_matching_event_is_not_dispatched(self):
|
||||
"""When no handlers are registered for the event, run_action is not called."""
|
||||
pm = self._run_trigger_event(
|
||||
[], "channel_start", {"channel_name": "TEST"}
|
||||
)
|
||||
|
||||
pm.run_action.assert_not_called()
|
||||
|
||||
def test_no_plugin_config_query_when_no_handlers(self):
|
||||
pm = MagicMock()
|
||||
pm.iter_actions_for_event.return_value = []
|
||||
with patch(
|
||||
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||
), patch(
|
||||
"apps.connect.utils.EventSubscription.objects.filter",
|
||||
return_value=_empty_subscription_chain(),
|
||||
), patch(
|
||||
"apps.plugins.models.PluginConfig"
|
||||
) as mock_cfg:
|
||||
from apps.connect.utils import trigger_event
|
||||
|
||||
trigger_event("channel_start", {"channel_name": "TEST"})
|
||||
|
||||
mock_cfg.objects.filter.assert_not_called()
|
||||
|
||||
def test_plugin_action_failure_does_not_block_sibling_handlers(self):
|
||||
handlers = [
|
||||
("failing-plugin", "on_event"),
|
||||
("working-plugin", "on_event"),
|
||||
]
|
||||
|
||||
pm = MagicMock()
|
||||
pm.iter_actions_for_event.return_value = handlers
|
||||
pm.run_action.side_effect = [RuntimeError("boom"), {"status": "ok"}]
|
||||
|
||||
enabled_qs = MagicMock()
|
||||
enabled_qs.values_list.return_value = ["failing-plugin", "working-plugin"]
|
||||
|
||||
with patch(
|
||||
"apps.connect.utils.PluginManager.get", return_value=pm
|
||||
), patch(
|
||||
"apps.connect.utils.EventSubscription.objects.filter",
|
||||
return_value=_empty_subscription_chain(),
|
||||
), patch(
|
||||
"apps.plugins.models.PluginConfig"
|
||||
) as mock_cfg:
|
||||
mock_cfg.objects.filter.return_value = enabled_qs
|
||||
from apps.connect.utils import trigger_event
|
||||
|
||||
trigger_event("channel_start", {"channel_name": "TEST"})
|
||||
|
||||
self.assertEqual(pm.run_action.call_count, 2)
|
||||
pm.run_action.assert_any_call(
|
||||
"failing-plugin",
|
||||
"on_event",
|
||||
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||
)
|
||||
pm.run_action.assert_any_call(
|
||||
"working-plugin",
|
||||
"on_event",
|
||||
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# connect/utils.py
|
||||
import logging, json
|
||||
import logging
|
||||
from django.template import Template, Context
|
||||
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
||||
from .handlers.webhook import WebhookHandler
|
||||
|
|
@ -94,23 +94,44 @@ def trigger_event(event_name, payload):
|
|||
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
||||
plugins = pm.list_plugins()
|
||||
handlers = list(pm.iter_actions_for_event(event_name))
|
||||
if not handlers:
|
||||
return
|
||||
|
||||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
||||
for plugin in plugins:
|
||||
if not plugin["enabled"]:
|
||||
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
|
||||
from apps.plugins.models import PluginConfig
|
||||
|
||||
handler_keys = {key for key, _ in handlers}
|
||||
enabled_keys = set(
|
||||
PluginConfig.objects.filter(enabled=True, key__in=handler_keys).values_list(
|
||||
"key", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Dispatching event '%s' to %d plugin action(s) (%d enabled)",
|
||||
event_name,
|
||||
len(handlers),
|
||||
len(enabled_keys),
|
||||
)
|
||||
params = {"event": event_name, "payload": payload}
|
||||
for key, action_id in handlers:
|
||||
if key not in enabled_keys:
|
||||
logger.debug(
|
||||
"Skipping disabled plugin id=%s for event '%s'", key, event_name
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(json.dumps(plugin))
|
||||
for action in plugin["actions"]:
|
||||
if "events" in action and event_name in action["events"]:
|
||||
key = plugin["key"]
|
||||
params = {"event": event_name, "payload": payload}
|
||||
action_name = action.get("label") or action.get("id")
|
||||
action_id = action.get("id")
|
||||
logger.debug(
|
||||
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
|
||||
)
|
||||
if action_id:
|
||||
pm.run_action(key, action_id, params)
|
||||
logger.debug(
|
||||
"Triggering plugin action for event '%s' on plugin id=%s action=%s",
|
||||
event_name,
|
||||
key,
|
||||
action_id,
|
||||
)
|
||||
try:
|
||||
pm.run_action(key, action_id, params)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Plugin action failed for event '%s' on plugin id=%s action=%s",
|
||||
event_name,
|
||||
key,
|
||||
action_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import logging, os
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from datetime import timedelta
|
||||
from .models import EPGSource, ProgramData, EPGData
|
||||
from .serializers import (
|
||||
|
|
@ -13,10 +21,14 @@ from .serializers import (
|
|||
ProgramDetailSerializer,
|
||||
EPGSourceSerializer,
|
||||
EPGDataSerializer,
|
||||
ProgramSearchResultSerializer,
|
||||
)
|
||||
from .tasks import refresh_epg_data
|
||||
from .tasks import refresh_epg_data, find_current_program_for_tvg_id
|
||||
from .query_utils import parse_text_query
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
IsAdmin,
|
||||
IsStandardUser,
|
||||
permission_classes_by_action,
|
||||
permission_classes_by_method,
|
||||
)
|
||||
|
|
@ -41,6 +53,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
if self.action in ('sd_lineups', 'sd_lineups_search'):
|
||||
if self.request.method == 'GET':
|
||||
return [IsStandardUser()]
|
||||
return [IsAdmin()]
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
@ -102,16 +118,372 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
def _sd_authenticate(self, source):
|
||||
"""
|
||||
Authenticate with Schedules Direct using stored credentials.
|
||||
Returns (token, None) on success or (None, Response) on failure.
|
||||
"""
|
||||
import hashlib
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
username = (source.username or '').strip()
|
||||
password = (source.password or '').strip()
|
||||
if not username or not password:
|
||||
return None, Response(
|
||||
{"error": "Username and password are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_response = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=dispatcharr_http_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
auth_response.raise_for_status()
|
||||
token = auth_response.json().get('token')
|
||||
if not token:
|
||||
return None, Response(
|
||||
{"error": "Authentication failed. Check your credentials."},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
return token, None
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return None, Response(
|
||||
{"error": f"Authentication failed: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
def _get_sd_reset_at(self, source):
|
||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||
return reset_at_str
|
||||
|
||||
def _get_sd_changes_remaining(self, source):
|
||||
"""
|
||||
Retrieve stored changesRemaining from EPGSource model field.
|
||||
If a reset timestamp exists and has passed (midnight UTC), clears the
|
||||
lockout automatically so the user can make adds again.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
changes_remaining = cp.get('sd_changes_remaining')
|
||||
reset_at_str = cp.get('sd_changes_reset_at')
|
||||
from django.utils.dateparse import parse_datetime
|
||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||
|
||||
# If we have a reset timestamp and it has passed, clear the lockout
|
||||
if changes_remaining == 0 and reset_at:
|
||||
if timezone.now() >= reset_at:
|
||||
cp = source.custom_properties or {}
|
||||
cp.pop('sd_changes_remaining', None)
|
||||
cp.pop('sd_changes_reset_at', None)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
return None
|
||||
|
||||
return changes_remaining
|
||||
|
||||
def _save_sd_changes_remaining(self, source, changes_remaining):
|
||||
"""Persist changesRemaining to EPGSource model field."""
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = changes_remaining
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
def _save_sd_lockout(self, source):
|
||||
"""
|
||||
Persist a hard lockout to EPGSource custom_properties when SD returns
|
||||
4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at
|
||||
00:00Z (midnight UTC) per SD's documented behavior — error 4100 states
|
||||
"lineup changes for today" and all SD rate counters reset at midnight UTC.
|
||||
Lockout clears automatically when the next midnight UTC passes.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
now = timezone.now()
|
||||
# Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window
|
||||
tomorrow = (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0,
|
||||
tzinfo=dt_timezone.utc
|
||||
)
|
||||
reset_at = tomorrow
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = 0
|
||||
cp['sd_changes_reset_at'] = reset_at.isoformat()
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
logger.warning(
|
||||
f"SD source {source.id}: daily add limit reached (4100). "
|
||||
f"Lockout set until {reset_at.isoformat()}."
|
||||
)
|
||||
|
||||
def _fetch_sd_countries(self):
|
||||
"""Fetch the SD country list (token not required; User-Agent is)."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/available/countries",
|
||||
headers=dispatcharr_http_headers(content_type=None),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Failed to fetch SD countries: {e}")
|
||||
return None
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
||||
def sd_lineups(self, request, pk=None):
|
||||
"""
|
||||
GET — list lineups currently on the SD account
|
||||
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = dispatcharr_http_headers(token=token)
|
||||
|
||||
if request.method == "GET":
|
||||
countries = self._fetch_sd_countries()
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/lineups",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 4102:
|
||||
return Response({
|
||||
"lineups": [],
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
||||
"countries": countries,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
||||
return Response({
|
||||
"lineups": lineups,
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"countries": countries,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to fetch lineups: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "POST":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.put(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
|
||||
if resp.status_code == 400 or resp.status_code == 403:
|
||||
if sd_code == 4100:
|
||||
self._save_sd_lockout(source)
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.",
|
||||
"changes_remaining": 0,
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 4101:
|
||||
return Response({
|
||||
"error": "max_lineups_reached",
|
||||
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 2100:
|
||||
return Response({
|
||||
"error": "duplicate_lineup",
|
||||
"message": "This lineup is already on your Schedules Direct account.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response({
|
||||
"error": sd_data.get('message', 'Failed to add lineup.'),
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
# Persist changesRemaining to custom_properties
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
|
||||
logger.info(
|
||||
f"SD lineup added for source {source.id}: {lineup_id}. "
|
||||
f"changesRemaining: {changes_remaining}"
|
||||
)
|
||||
|
||||
# Re-fetch stations so the new lineup's stations are available for matching
|
||||
from apps.epg.tasks import fetch_schedules_direct_stations
|
||||
fetch_schedules_direct_stations.delay(source.id)
|
||||
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": changes_remaining,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to add lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "DELETE":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.delete(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 2103:
|
||||
return Response({
|
||||
"response": "OK",
|
||||
"code": 0,
|
||||
"message": "Lineup not found on account — already removed.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
resp.raise_for_status()
|
||||
sd_data = resp.json()
|
||||
# SD returns changesRemaining on deletes — persist it
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to remove lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
||||
def sd_lineups_search(self, request, pk=None):
|
||||
"""
|
||||
Search available headends/lineups by country and postal code.
|
||||
Body: {"country": "USA", "postalcode": "07030"}
|
||||
Returns a flat list of lineups across all matching headends.
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
country = request.data.get('country', '').strip()
|
||||
postalcode = request.data.get('postalcode', '').strip()
|
||||
if not country or not postalcode:
|
||||
return Response(
|
||||
{"error": "country and postalcode are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = dispatcharr_http_headers(token=token)
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/headends",
|
||||
params={'country': country, 'postalcode': postalcode},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
headends = resp.json()
|
||||
lineups = []
|
||||
for headend in headends:
|
||||
for lineup in headend.get('lineups', []):
|
||||
lineups.append({
|
||||
'lineup': lineup.get('lineup'),
|
||||
'name': lineup.get('name'),
|
||||
'transport': headend.get('transport'),
|
||||
'location': headend.get('location'),
|
||||
'headend': headend.get('headend'),
|
||||
})
|
||||
return Response({"lineups": lineups})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to search headends: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class ProgramSearchPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 500
|
||||
|
||||
|
||||
class ProgramViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for EPG programs"""
|
||||
|
||||
queryset = ProgramData.objects.select_related("epg").all()
|
||||
serializer_class = ProgramDataSerializer
|
||||
|
||||
# Per-source in-memory caches (token and error state)
|
||||
_sd_poster_token_cache: dict = {}
|
||||
_sd_poster_error_cache: dict = {}
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'poster':
|
||||
return [AllowAny()]
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
|
|
@ -127,10 +499,315 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
||||
def poster(self, request, pk=None):
|
||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
program = self.get_object()
|
||||
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
|
||||
if not poster_sd_url:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
source = program.epg.epg_source if program.epg else None
|
||||
if not source or source.source_type != 'schedules_direct':
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
|
||||
if error_cache and time.time() < error_cache['until']:
|
||||
return Response(
|
||||
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
cached = ProgramViewSet._sd_poster_token_cache.get(source.id)
|
||||
token = cached['token'] if cached and time.time() < cached['expires'] else None
|
||||
|
||||
if not token:
|
||||
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_resp = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': source.username, 'password': sha1_password},
|
||||
headers=dispatcharr_http_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
auth_data = auth_resp.json()
|
||||
token = auth_data.get('token')
|
||||
if not token:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': auth_data.get('message', 'Authentication failed'),
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
token_expires = auth_data.get('tokenExpires', time.time() + 86400)
|
||||
ProgramViewSet._sd_poster_token_cache[source.id] = {
|
||||
'token': token,
|
||||
'expires': token_expires,
|
||||
}
|
||||
except http_requests.exceptions.RequestException:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': 'Network error reaching Schedules Direct',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
try:
|
||||
img_resp = http_requests.get(
|
||||
poster_sd_url,
|
||||
headers=dispatcharr_http_headers(token=token, content_type=None),
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if img_resp.status_code in (401, 403):
|
||||
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': f'SD returned {img_resp.status_code}',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
if img_resp.status_code == 400:
|
||||
try:
|
||||
err_code = img_resp.json().get('code')
|
||||
except Exception:
|
||||
err_code = None
|
||||
if err_code == 5002:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': 'Daily image download limit reached (SD error 5002)',
|
||||
}
|
||||
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
if img_resp.status_code != 200:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
from django.http import HttpResponse
|
||||
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
||||
response = HttpResponse(img_resp.content, content_type=content_type)
|
||||
response['Cache-Control'] = 'public, max-age=86400'
|
||||
return response
|
||||
|
||||
except http_requests.exceptions.RequestException:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG programs.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(
|
||||
summary="Search EPG programs",
|
||||
description="""
|
||||
**Advanced EPG program search with multiple filter types and complex query support.**
|
||||
|
||||
### Text Search Features
|
||||
|
||||
**Title and Description Search**:
|
||||
- Supports AND/OR logical operators (case-insensitive: `and`/`AND` both work)
|
||||
- Wrap phrases in double quotes to match them literally: `"Law and Order"`
|
||||
- Parenthetical grouping for complex queries: `(Newcastle OR NEW) AND (Villa OR AST)`
|
||||
- Regex pattern matching with `title_regex=true` (evaluated by the database engine)
|
||||
- Whole word matching with `title_whole_words=true` to avoid partial matches
|
||||
|
||||
**Examples**:
|
||||
- Simple: `title=football`
|
||||
- AND operator: `title=premier AND league`
|
||||
- OR operator: `title=Newcastle OR Villa`
|
||||
- Quoted phrase: `title="Law and Order"` (matches the exact phrase; 'and' is literal)
|
||||
- Mixed: `title="Law and Order" AND crime`
|
||||
- Nested groups: `title=(Newcastle OR NEW) AND (Villa OR AST)`
|
||||
- Regex: `title=^Premier&title_regex=true` (programs starting with "Premier")
|
||||
- Whole words: `title=NEW&title_whole_words=true` (matches "NEW" but not "News")
|
||||
|
||||
### Time Filtering
|
||||
|
||||
**airing_at**: Find programs airing at a specific moment (start_time ≤ airing_at < end_time)
|
||||
|
||||
**Time ranges**: Use combinations of start_after, start_before, end_after, end_before
|
||||
|
||||
### Response Customization
|
||||
|
||||
**fields**: Comma-separated list to include only specific fields in response
|
||||
- Available: id, title, sub_title, description, start_time, end_time, tvg_id, custom_properties, epg_source, epg_name, epg_icon_url, channels, streams
|
||||
|
||||
### Pagination
|
||||
|
||||
- Default: 50 results per page
|
||||
- Maximum: 500 results per page
|
||||
- Use `page` and `page_size` parameters to navigate results
|
||||
""",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
'title',
|
||||
OpenApiTypes.STR,
|
||||
description='Title search query. Supports AND/OR operators (case-insensitive), quoted phrases, and parentheses. Double-quote a phrase to match it literally: `"Law and Order"`. Unquoted space-separated terms are matched as a phrase; use AND/OR to combine separate terms.',
|
||||
),
|
||||
OpenApiParameter('title_regex', OpenApiTypes.BOOL, description='Enable regex matching for title (case-insensitive, default: false). e.g. `^The` matches titles starting with "The".'),
|
||||
OpenApiParameter('title_whole_words', OpenApiTypes.BOOL, description='Match whole words only in title (default: false). e.g. `new` matches "Newcastle" normally but not with whole words enabled.'),
|
||||
OpenApiParameter(
|
||||
'description',
|
||||
OpenApiTypes.STR,
|
||||
description='Description search query. Same syntax and features as title search.'
|
||||
),
|
||||
OpenApiParameter('description_regex', OpenApiTypes.BOOL, description='Enable regex matching for description (case-insensitive, default: false).'),
|
||||
OpenApiParameter('description_whole_words', OpenApiTypes.BOOL, description='Match whole words only in description (default: false). Same behaviour as title_whole_words.'),
|
||||
OpenApiParameter('start_after', OpenApiTypes.DATETIME, description='Filter programs starting at or after this time. ISO 8601 format, e.g. `2026-02-14T18:00:00Z`.'),
|
||||
OpenApiParameter('start_before', OpenApiTypes.DATETIME, description='Filter programs starting at or before this time. ISO 8601 format.'),
|
||||
OpenApiParameter('end_after', OpenApiTypes.DATETIME, description='Filter programs ending at or after this time. ISO 8601 format.'),
|
||||
OpenApiParameter('end_before', OpenApiTypes.DATETIME, description='Filter programs ending at or before this time. ISO 8601 format.'),
|
||||
OpenApiParameter('airing_at', OpenApiTypes.DATETIME, description='Find programs airing at this exact moment (start_time ≤ airing_at < end_time). ISO 8601 format, e.g. `2026-02-14T20:00:00Z`.'),
|
||||
OpenApiParameter('channel', OpenApiTypes.STR, description='Filter by channel name (case-insensitive substring match). e.g. `BBC One`, `Sky Sports`.'),
|
||||
OpenApiParameter('channel_id', OpenApiTypes.INT, description='Filter by exact channel ID.'),
|
||||
OpenApiParameter('tvg_id', OpenApiTypes.STR, description='Filter by EPG tvg_id (exact match). e.g. `bbcone.uk`.'),
|
||||
OpenApiParameter('stream', OpenApiTypes.STR, description='Filter by stream name (case-insensitive substring match).'),
|
||||
OpenApiParameter('group', OpenApiTypes.STR, description='Filter by channel group or stream group name (case-insensitive substring match). e.g. `Sports`, `UK Channels`.'),
|
||||
OpenApiParameter('epg_source', OpenApiTypes.INT, description='Filter by EPG source ID.'),
|
||||
OpenApiParameter('fields', OpenApiTypes.STR, description='Comma-separated list of fields to include. Omit to return all fields. e.g. `title,start_time,end_time`.'),
|
||||
OpenApiParameter('page', OpenApiTypes.INT, description='Page number for pagination (default: 1).'),
|
||||
OpenApiParameter('page_size', OpenApiTypes.INT, description='Results per page (default: 50, max: 500).'),
|
||||
],
|
||||
responses={200: ProgramSearchResultSerializer(many=True)},
|
||||
tags=['EPG'],
|
||||
)
|
||||
@action(detail=False, methods=['get'], url_path='search', permission_classes=[IsStandardUser])
|
||||
def search(self, request):
|
||||
params = request.query_params
|
||||
|
||||
# Build base queryset with prefetching
|
||||
queryset = ProgramData.objects.select_related(
|
||||
'epg', 'epg__epg_source'
|
||||
).prefetch_related(
|
||||
'epg__channels', 'epg__channels__channel_group',
|
||||
'epg__channels__streams', 'epg__channels__streams__channel_group',
|
||||
'epg__channels__streams__m3u_account',
|
||||
)
|
||||
|
||||
filters = Q()
|
||||
|
||||
# Text filters
|
||||
title = params.get('title')
|
||||
if title:
|
||||
title_regex = params.get('title_regex', '').lower() in ('true', '1', 'yes')
|
||||
title_whole_words = params.get('title_whole_words', '').lower() in ('true', '1', 'yes')
|
||||
filters &= parse_text_query('title', title, use_regex=title_regex, whole_words=title_whole_words)
|
||||
|
||||
description = params.get('description')
|
||||
if description:
|
||||
desc_regex = params.get('description_regex', '').lower() in ('true', '1', 'yes')
|
||||
desc_whole_words = params.get('description_whole_words', '').lower() in ('true', '1', 'yes')
|
||||
filters &= parse_text_query('description', description, use_regex=desc_regex, whole_words=desc_whole_words)
|
||||
|
||||
# Time filters with validation
|
||||
start_after = params.get('start_after')
|
||||
if start_after:
|
||||
dt = parse_datetime(start_after)
|
||||
if dt is None:
|
||||
return Response(
|
||||
{"error": f"Invalid datetime format for start_after: {start_after}. Use ISO 8601 format (e.g., 2026-02-14T18:00:00Z)"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
filters &= Q(start_time__gte=dt)
|
||||
|
||||
start_before = params.get('start_before')
|
||||
if start_before:
|
||||
dt = parse_datetime(start_before)
|
||||
if dt is None:
|
||||
return Response(
|
||||
{"error": f"Invalid datetime format for start_before: {start_before}. Use ISO 8601 format."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
filters &= Q(start_time__lte=dt)
|
||||
|
||||
end_after = params.get('end_after')
|
||||
if end_after:
|
||||
dt = parse_datetime(end_after)
|
||||
if dt is None:
|
||||
return Response(
|
||||
{"error": f"Invalid datetime format for end_after: {end_after}. Use ISO 8601 format."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
filters &= Q(end_time__gte=dt)
|
||||
|
||||
end_before = params.get('end_before')
|
||||
if end_before:
|
||||
dt = parse_datetime(end_before)
|
||||
if dt is None:
|
||||
return Response(
|
||||
{"error": f"Invalid datetime format for end_before: {end_before}. Use ISO 8601 format."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
filters &= Q(end_time__lte=dt)
|
||||
|
||||
airing_at = params.get('airing_at')
|
||||
if airing_at:
|
||||
dt = parse_datetime(airing_at)
|
||||
if dt is None:
|
||||
return Response(
|
||||
{"error": f"Invalid datetime format for airing_at: {airing_at}. Use ISO 8601 format."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
filters &= Q(start_time__lte=dt, end_time__gt=dt)
|
||||
|
||||
# Channel/stream filters
|
||||
channel = params.get('channel')
|
||||
if channel:
|
||||
filters &= Q(epg__channels__name__icontains=channel)
|
||||
|
||||
channel_id = params.get('channel_id')
|
||||
if channel_id:
|
||||
try:
|
||||
filters &= Q(epg__channels__id=int(channel_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
tvg_id = params.get('tvg_id')
|
||||
if tvg_id:
|
||||
filters &= Q(epg__tvg_id=tvg_id)
|
||||
|
||||
stream = params.get('stream')
|
||||
if stream:
|
||||
filters &= Q(epg__channels__streams__name__icontains=stream)
|
||||
|
||||
group = params.get('group')
|
||||
if group:
|
||||
filters &= (
|
||||
Q(epg__channels__channel_group__name__icontains=group)
|
||||
| Q(epg__channels__streams__channel_group__name__icontains=group)
|
||||
)
|
||||
|
||||
epg_source = params.get('epg_source')
|
||||
if epg_source:
|
||||
try:
|
||||
filters &= Q(epg__epg_source__id=int(epg_source))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
queryset = queryset.filter(filters).distinct().order_by('start_time')
|
||||
|
||||
# Restrict results to programs on channels the user can access
|
||||
user = request.user
|
||||
if user.user_level < 10:
|
||||
access_filter = Q(epg__channels__user_level__lte=user.user_level)
|
||||
custom_props = user.custom_properties or {}
|
||||
if custom_props.get('hide_adult_content', False):
|
||||
access_filter &= Q(epg__channels__is_adult=False)
|
||||
queryset = queryset.filter(access_filter).distinct()
|
||||
|
||||
# Resolve field selection before serialization so expensive methods can short-circuit
|
||||
requested_fields = params.get('fields')
|
||||
allowed = set(f.strip() for f in requested_fields.split(',')) if requested_fields else None
|
||||
|
||||
# Paginate
|
||||
paginator = ProgramSearchPagination()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
serializer = ProgramSearchResultSerializer(page, many=True, context={'fields': allowed, 'user': request.user})
|
||||
data = serializer.data
|
||||
|
||||
if allowed:
|
||||
data = [{k: v for k, v in item.items() if k in allowed} for item in data]
|
||||
|
||||
return paginator.get_paginated_response(data)
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# 3) EPG Grid View
|
||||
|
|
@ -175,7 +852,7 @@ class EPGGridAPIView(APIView):
|
|||
# Get channels with custom dummy EPG sources (generate on-demand with patterns)
|
||||
channels_with_custom_dummy = Channel.objects.filter(
|
||||
epg_data__epg_source__source_type='dummy'
|
||||
).distinct()
|
||||
).select_related('epg_data__epg_source').distinct()
|
||||
|
||||
# Log what we found
|
||||
without_count = channels_without_epg.count()
|
||||
|
|
@ -307,7 +984,9 @@ class EPGGridAPIView(APIView):
|
|||
channel_name=name_to_parse,
|
||||
num_days=1,
|
||||
program_length_hours=4,
|
||||
epg_source=epg_source
|
||||
epg_source=epg_source,
|
||||
export_lookback=one_hour_ago,
|
||||
export_cutoff=twenty_four_hours_later,
|
||||
)
|
||||
|
||||
# Custom dummy should always return data (either from patterns or fallback)
|
||||
|
|
@ -403,13 +1082,19 @@ class EPGGridAPIView(APIView):
|
|||
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
|
||||
)
|
||||
|
||||
# Combine regular and dummy programs
|
||||
all_programs = list(serialized_programs) + dummy_programs
|
||||
# Combine regular and dummy programs in place to avoid copying the large list
|
||||
serialized_programs.extend(dummy_programs)
|
||||
logger.debug(
|
||||
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
||||
f"EPGGridAPIView: Returning {len(serialized_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
||||
)
|
||||
|
||||
return Response({"data": all_programs}, status=status.HTTP_200_OK)
|
||||
# The grid materializes tens of thousands of program dicts plus the
|
||||
# rendered JSON; trim once the response is sent so worker RSS does not
|
||||
# ratchet up per request.
|
||||
from core.utils import spawn_memory_trim
|
||||
response = Response({"data": serialized_programs}, status=status.HTTP_200_OK)
|
||||
response._resource_closers.append(spawn_memory_trim)
|
||||
return response
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
|
|
@ -427,29 +1112,42 @@ class EPGImportAPIView(APIView):
|
|||
return [Authenticated()]
|
||||
|
||||
@extend_schema(
|
||||
description="Triggers an EPG data import",
|
||||
description="Triggers an EPG data refresh for the given source.",
|
||||
request=inline_serializer(
|
||||
name="EPGImportRequest",
|
||||
fields={
|
||||
"id": serializers.IntegerField(help_text="ID of the EPG source to refresh."),
|
||||
},
|
||||
),
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
logger.info("EPGImportAPIView: Received request to import EPG data.")
|
||||
epg_id = request.data.get("id", None)
|
||||
force = bool(request.data.get("force", False))
|
||||
|
||||
# Check if this is a dummy EPG source
|
||||
try:
|
||||
# Reject dummy sources with a narrow existence query, no full row load.
|
||||
if epg_id is not None:
|
||||
from .models import EPGSource
|
||||
epg_source = EPGSource.objects.get(id=epg_id)
|
||||
if epg_source.source_type == 'dummy':
|
||||
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
|
||||
|
||||
if EPGSource.objects.filter(
|
||||
id=epg_id, source_type="dummy"
|
||||
).exists():
|
||||
logger.info(
|
||||
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
|
||||
epg_id,
|
||||
)
|
||||
return Response(
|
||||
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
|
||||
{
|
||||
"success": False,
|
||||
"message": "Dummy EPG sources do not require refreshing.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except EPGSource.DoesNotExist:
|
||||
pass # Let the task handle the missing source
|
||||
|
||||
refresh_epg_data.delay(epg_id) # Trigger Celery task
|
||||
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
|
||||
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
||||
return Response(
|
||||
{"success": True, "message": "EPG data import initiated."},
|
||||
{"success": True, "message": "EPG data refresh initiated."},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
|
@ -500,19 +1198,100 @@ class CurrentProgramsAPIView(APIView):
|
|||
allow_null=True,
|
||||
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
|
||||
),
|
||||
"epg_data_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Array of EPG data IDs. Can be used instead of channel_ids.",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={200: ProgramDataSerializer(many=True)},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Get IDs from request body
|
||||
channel_uuids = request.data.get('channel_uuids', None)
|
||||
epg_data_ids = request.data.get('epg_data_ids', None)
|
||||
|
||||
# Validate that at most one type of ID is provided
|
||||
if channel_uuids is not None and epg_data_ids is not None:
|
||||
return Response(
|
||||
{"error": "Provide either channel_uuids or epg_data_ids, not both"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get current time
|
||||
now = timezone.now()
|
||||
|
||||
# If epg_data_ids are provided, query directly by EPG data
|
||||
if epg_data_ids is not None:
|
||||
if not isinstance(epg_data_ids, list):
|
||||
return Response(
|
||||
{"error": "epg_data_ids must be an array of integers or null"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
epg_data_ids = [int(eid) for eid in epg_data_ids]
|
||||
except (ValueError, TypeError):
|
||||
return Response(
|
||||
{"error": "epg_data_ids must contain valid integers"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Limit to 50 IDs per request
|
||||
epg_data_ids = epg_data_ids[:50]
|
||||
|
||||
epg_data_entries = EPGData.objects.select_related('epg_source').filter(
|
||||
id__in=epg_data_ids
|
||||
)
|
||||
|
||||
# Batch-fetch current programs for all requested EPG entries in one query
|
||||
db_programs = ProgramData.objects.filter(
|
||||
epg__in=epg_data_entries, start_time__lte=now, end_time__gt=now
|
||||
).select_related('epg')
|
||||
# Map epg_data id -> first matching program
|
||||
programs_by_epg = {}
|
||||
for prog in db_programs:
|
||||
if prog.epg_id not in programs_by_epg:
|
||||
programs_by_epg[prog.epg_id] = prog
|
||||
|
||||
current_programs = []
|
||||
for epg_data in epg_data_entries:
|
||||
# Check batch-fetched DB results first
|
||||
program = programs_by_epg.get(epg_data.id)
|
||||
|
||||
if program:
|
||||
program_data = ProgramDataSerializer(program).data
|
||||
program_data['epg_data_id'] = epg_data.id
|
||||
current_programs.append(program_data)
|
||||
continue
|
||||
|
||||
# Skip dummy sources
|
||||
if epg_data.epg_source and epg_data.epg_source.source_type == 'dummy':
|
||||
continue
|
||||
|
||||
# Fall back to byte-offset index lookup, pass the object to avoid re-fetch
|
||||
result = find_current_program_for_tvg_id(epg_data)
|
||||
|
||||
if result == "timeout":
|
||||
current_programs.append({
|
||||
"epg_data_id": epg_data.id,
|
||||
"parsing": True,
|
||||
})
|
||||
elif result is not None:
|
||||
result['epg_data_id'] = epg_data.id
|
||||
current_programs.append(result)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
||||
# Otherwise, use channel-based query
|
||||
# Import Channel model
|
||||
from apps.channels.models import Channel
|
||||
|
||||
# Build query for channels with EPG data
|
||||
query = Channel.objects.filter(epg_data__isnull=False)
|
||||
|
||||
channel_uuids = request.data.get('channel_uuids', None)
|
||||
|
||||
if channel_uuids is not None:
|
||||
if not isinstance(channel_uuids, list):
|
||||
return Response(
|
||||
|
|
@ -524,9 +1303,6 @@ class CurrentProgramsAPIView(APIView):
|
|||
# Get channels with EPG data
|
||||
channels = query.select_related('epg_data')
|
||||
|
||||
# Get current time
|
||||
now = timezone.now()
|
||||
|
||||
# Build list of current programs
|
||||
current_programs = []
|
||||
|
||||
|
|
@ -543,5 +1319,5 @@ class CurrentProgramsAPIView(APIView):
|
|||
program_data['channel_uuid'] = str(channel.uuid)
|
||||
current_programs.append(program_data)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
18
apps/epg/migrations/0022_alter_epgdata_name.py
Normal file
18
apps/epg/migrations/0022_alter_epgdata_name.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 6.0.4 on 2026-04-21 14:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0021_epgsource_priority'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='epgdata',
|
||||
name='name',
|
||||
field=models.CharField(max_length=512),
|
||||
),
|
||||
]
|
||||
21
apps/epg/migrations/0023_epgsource_programme_index.py
Normal file
21
apps/epg/migrations/0023_epgsource_programme_index.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0022_alter_epgdata_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='programme_index',
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text='Byte-offset index mapping tvg_id to file positions, built after each EPG refresh',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Generated by Django 6.0.5 on 2026-05-31 16:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0023_epgsource_programme_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='epgsource',
|
||||
name='api_key',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, help_text='Password for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, help_text='Username for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='programdata',
|
||||
name='program_id',
|
||||
field=models.CharField(blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.', max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='epgsource',
|
||||
name='custom_properties',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for source-specific configuration', null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SDProgramMD5',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('program_id', models.CharField(help_text='Schedules Direct programID (e.g. EP123456789)', max_length=64)),
|
||||
('md5', models.CharField(help_text='MD5 hash of the program metadata from Schedules Direct', max_length=22)),
|
||||
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_program_md5s', to='epg.epgsource')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('epg_source', 'program_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SDScheduleMD5',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('station_id', models.CharField(help_text='Schedules Direct stationID', max_length=20)),
|
||||
('date', models.DateField(help_text='Schedule date (UTC)')),
|
||||
('md5', models.CharField(help_text='MD5 hash of the schedule for this station/date from Schedules Direct', max_length=22)),
|
||||
('last_modified', models.DateTimeField(help_text='Last modified timestamp from Schedules Direct')),
|
||||
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_schedule_md5s', to='epg.epgsource')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['epg_source', 'station_id'], name='epg_sdsched_epg_sou_0d700e_idx')],
|
||||
'unique_together': {('epg_source', 'station_id', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class AddIndexConcurrentlyIfPostgres(AddIndexConcurrently):
|
||||
"""Create the index CONCURRENTLY on PostgreSQL (no table lock on large
|
||||
tables), falling back to a normal blocking AddIndex on other backends
|
||||
such as the sqlite dev/test fallback."""
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||
if schema_editor.connection.vendor == 'postgresql':
|
||||
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||
else:
|
||||
migrations.AddIndex.database_forwards(
|
||||
self, app_label, schema_editor, from_state, to_state
|
||||
)
|
||||
|
||||
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||
if schema_editor.connection.vendor == 'postgresql':
|
||||
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
||||
else:
|
||||
migrations.AddIndex.database_backwards(
|
||||
self, app_label, schema_editor, from_state, to_state
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('epg', '0024_remove_epgsource_api_key_epgsource_password_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrentlyIfPostgres(
|
||||
model_name='programdata',
|
||||
index=models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||
),
|
||||
]
|
||||
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_index_forward(apps, schema_editor):
|
||||
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||
rows = list(
|
||||
EPGSource.objects.exclude(programme_index__isnull=True).values_list(
|
||||
'id', 'programme_index'
|
||||
)
|
||||
)
|
||||
for source_id, data in rows:
|
||||
EPGSourceIndex.objects.update_or_create(
|
||||
source_id=source_id, defaults={'data': data}
|
||||
)
|
||||
|
||||
|
||||
def copy_index_backward(apps, schema_editor):
|
||||
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||
for source_id, data in EPGSourceIndex.objects.values_list('source_id', 'data'):
|
||||
EPGSource.objects.filter(id=source_id).update(programme_index=data)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0025_programdata_epg_id_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EPGSourceIndex',
|
||||
fields=[
|
||||
('source', models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
primary_key=True,
|
||||
related_name='index_record',
|
||||
serialize=False,
|
||||
to='epg.epgsource',
|
||||
)),
|
||||
('data', models.JSONField(blank=True, default=None, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(copy_index_forward, copy_index_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='epgsource',
|
||||
name='programme_index',
|
||||
),
|
||||
]
|
||||
|
|
@ -30,7 +30,10 @@ class EPGSource(models.Model):
|
|||
name = models.CharField(max_length=255, unique=True)
|
||||
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
|
||||
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
|
||||
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
|
||||
username = models.CharField(max_length=255, blank=True, null=True,
|
||||
help_text='Username for credential-based EPG sources (e.g. Schedules Direct)')
|
||||
password = models.CharField(max_length=255, blank=True, null=True,
|
||||
help_text='Password for credential-based EPG sources (e.g. Schedules Direct)')
|
||||
is_active = models.BooleanField(default=True)
|
||||
file_path = models.CharField(max_length=1024, blank=True, null=True)
|
||||
extracted_file_path = models.CharField(max_length=1024, blank=True, null=True,
|
||||
|
|
@ -43,7 +46,7 @@ class EPGSource(models.Model):
|
|||
default=dict,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)"
|
||||
help_text="Custom properties for source-specific configuration"
|
||||
)
|
||||
priority = models.PositiveIntegerField(
|
||||
default=0,
|
||||
|
|
@ -74,18 +77,13 @@ class EPGSource(models.Model):
|
|||
def get_cache_file(self):
|
||||
import mimetypes
|
||||
|
||||
# Use a temporary extension for initial download
|
||||
# The actual extension will be determined after content inspection
|
||||
file_ext = ".tmp"
|
||||
|
||||
# If file_path is already set and contains an extension, use that
|
||||
# This handles cases where we've already detected the proper type
|
||||
if self.file_path and os.path.exists(self.file_path):
|
||||
_, existing_ext = os.path.splitext(self.file_path)
|
||||
if existing_ext:
|
||||
file_ext = existing_ext
|
||||
else:
|
||||
# Try to detect the MIME type and map to extension
|
||||
mime_type, _ = mimetypes.guess_type(self.file_path)
|
||||
if mime_type:
|
||||
if mime_type == 'application/gzip' or mime_type == 'application/x-gzip':
|
||||
|
|
@ -94,50 +92,66 @@ class EPGSource(models.Model):
|
|||
file_ext = '.zip'
|
||||
elif mime_type == 'application/xml' or mime_type == 'text/xml':
|
||||
file_ext = '.xml'
|
||||
# For files without mime type detection, try peeking at content
|
||||
else:
|
||||
try:
|
||||
with open(self.file_path, 'rb') as f:
|
||||
header = f.read(4)
|
||||
# Check for gzip magic number (1f 8b)
|
||||
if header[:2] == b'\x1f\x8b':
|
||||
file_ext = '.gz'
|
||||
# Check for zip magic number (PK..)
|
||||
elif header[:2] == b'PK':
|
||||
file_ext = '.zip'
|
||||
# Check for XML
|
||||
elif header[:5] == b'<?xml' or header[:5] == b'<tv>':
|
||||
file_ext = '.xml'
|
||||
except Exception as e:
|
||||
# If we can't read the file, just keep the default extension
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
filename = f"{self.id}{file_ext}"
|
||||
|
||||
# Build full path in MEDIA_ROOT/cached_epg
|
||||
cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
cache = os.path.join(cache_dir, filename)
|
||||
|
||||
return cache
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
|
||||
# Don't modify updated_at for regular updates
|
||||
kwargs.setdefault('update_fields', [])
|
||||
if 'updated_at' in kwargs['update_fields']:
|
||||
kwargs['update_fields'].remove('updated_at')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def programme_index(self):
|
||||
"""Byte-offset index for this source, read on demand from the separate
|
||||
EPGSourceIndex table so the multi-MB blob is never pulled into EPGSource
|
||||
queries or select_related JOINs. Returns the stored dict or None."""
|
||||
return (
|
||||
EPGSourceIndex.objects.filter(source_id=self.pk)
|
||||
.values_list('data', flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
class EPGSourceIndex(models.Model):
|
||||
"""Byte-offset programme index for an EPGSource, stored in its own table.
|
||||
|
||||
Kept out of EPGSource so the multi-MB JSON blob is only loaded when read
|
||||
explicitly, never when querying or joining EPGSource rows.
|
||||
"""
|
||||
source = models.OneToOneField(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='index_record',
|
||||
primary_key=True,
|
||||
)
|
||||
data = models.JSONField(null=True, blank=True, default=None)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Programme index for source {self.source_id}"
|
||||
|
||||
|
||||
class EPGData(models.Model):
|
||||
# Removed the Channel foreign key. We now just store the original tvg_id
|
||||
# and a name (which might simply be the tvg_id if no real channel exists).
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=255)
|
||||
name = models.CharField(max_length=512)
|
||||
icon_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
|
|
@ -154,7 +168,6 @@ class EPGData(models.Model):
|
|||
return f"EPG Data for {self.name}"
|
||||
|
||||
class ProgramData(models.Model):
|
||||
# Each programme is associated with an EPGData record.
|
||||
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
|
|
@ -162,7 +175,76 @@ class ProgramData(models.Model):
|
|||
sub_title = models.TextField(blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||
|
||||
class SDScheduleMD5(models.Model):
|
||||
"""
|
||||
Caches per-station per-date MD5 hashes from Schedules Direct.
|
||||
Used to detect schedule changes and avoid unnecessary re-downloads,
|
||||
minimizing API calls against SD's rate-limited endpoints.
|
||||
"""
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sd_schedule_md5s",
|
||||
)
|
||||
station_id = models.CharField(
|
||||
max_length=20,
|
||||
help_text="Schedules Direct stationID"
|
||||
)
|
||||
date = models.DateField(
|
||||
help_text="Schedule date (UTC)"
|
||||
)
|
||||
md5 = models.CharField(
|
||||
max_length=22,
|
||||
help_text="MD5 hash of the schedule for this station/date from Schedules Direct"
|
||||
)
|
||||
last_modified = models.DateTimeField(
|
||||
help_text="Last modified timestamp from Schedules Direct"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('epg_source', 'station_id', 'date')
|
||||
indexes = [
|
||||
models.Index(fields=['epg_source', 'station_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})"
|
||||
|
||||
|
||||
class SDProgramMD5(models.Model):
|
||||
"""
|
||||
Caches per-program MD5 hashes from Schedules Direct.
|
||||
Keyed by epg_source + program_id (SD's programID e.g. EP123456789).
|
||||
Used for program-level delta detection to avoid re-downloading unchanged
|
||||
program metadata, minimizing API calls against SD's rate-limited endpoints.
|
||||
"""
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sd_program_md5s",
|
||||
)
|
||||
program_id = models.CharField(
|
||||
max_length=64,
|
||||
help_text="Schedules Direct programID (e.g. EP123456789)"
|
||||
)
|
||||
md5 = models.CharField(
|
||||
max_length=22,
|
||||
help_text="MD5 hash of the program metadata from Schedules Direct"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('epg_source', 'program_id')
|
||||
|
||||
def __str__(self):
|
||||
return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})"
|
||||
|
|
|
|||
137
apps/epg/query_utils.py
Normal file
137
apps/epg/query_utils.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Shared query parsing helpers for EPG program text search.
|
||||
|
||||
Used by the EPG search API and the DVR series rule evaluator to apply
|
||||
the same boolean / quoted-phrase / regex / whole-word matching semantics.
|
||||
|
||||
Public API:
|
||||
parse_text_query(field, raw_value, use_regex=False, whole_words=False) -> Q
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
def build_q_object(field_name, term, use_regex=False, whole_words=False):
|
||||
"""Build a single Q object for one search term.
|
||||
|
||||
`whole_words` uses `\\y` on PostgreSQL and `\\b` everywhere else so the
|
||||
pattern actually anchors on a word boundary regardless of backend.
|
||||
"""
|
||||
term = term.strip()
|
||||
if not term:
|
||||
return Q()
|
||||
|
||||
if use_regex:
|
||||
return Q(**{f'{field_name}__iregex': term})
|
||||
if whole_words:
|
||||
from django.db import connection
|
||||
boundary = r'\y' if connection.vendor == 'postgresql' else r'\b'
|
||||
pattern = boundary + re.escape(term) + boundary
|
||||
return Q(**{f'{field_name}__iregex': pattern})
|
||||
return Q(**{f'{field_name}__icontains': term})
|
||||
|
||||
|
||||
def parse_text_query(field_name, raw_value, use_regex=False, whole_words=False):
|
||||
"""Parse a search expression into a Q object.
|
||||
|
||||
Supports:
|
||||
- AND / OR (case-insensitive) between bare terms.
|
||||
- Double-quoted phrases (atomic; never split on operators).
|
||||
- Parenthetical grouping with arbitrary nesting.
|
||||
- Regex mode (entire raw value is treated as a single regex).
|
||||
- Whole-word mode (each bare term anchored with word boundaries).
|
||||
|
||||
A bare value with no operators is matched as a single phrase via
|
||||
icontains (or regex / whole-word as configured).
|
||||
"""
|
||||
phrases = {}
|
||||
|
||||
def extract_quoted(text):
|
||||
def replacer(m):
|
||||
key = f'\x00P{len(phrases)}\x00'
|
||||
phrases[key] = m.group(1)
|
||||
return key
|
||||
return re.sub(r'"([^"]*)"', replacer, text)
|
||||
|
||||
processed = extract_quoted(raw_value)
|
||||
|
||||
def build_q(token):
|
||||
return build_q_object(field_name, phrases.get(token, token), use_regex, whole_words)
|
||||
|
||||
def parse_expression(expr):
|
||||
expr = expr.strip()
|
||||
|
||||
if '(' in expr:
|
||||
paren_start = expr.rfind('(')
|
||||
paren_end = expr.find(')', paren_start)
|
||||
if paren_end == -1:
|
||||
return Q()
|
||||
|
||||
group_q = parse_expression(expr[paren_start + 1:paren_end])
|
||||
|
||||
before_str = expr[:paren_start].rstrip()
|
||||
after_str = expr[paren_end + 1:].lstrip()
|
||||
|
||||
before_op = '&'
|
||||
if before_str.upper().endswith(' AND'):
|
||||
before_str = before_str[:-4].rstrip()
|
||||
elif before_str.upper().endswith(' OR'):
|
||||
before_str = before_str[:-3].rstrip()
|
||||
before_op = '|'
|
||||
|
||||
after_op = '&'
|
||||
after_upper = after_str.upper()
|
||||
if after_upper.startswith('AND '):
|
||||
after_str = after_str[4:].lstrip()
|
||||
elif after_upper.startswith('OR '):
|
||||
after_str = after_str[3:].lstrip()
|
||||
after_op = '|'
|
||||
|
||||
result = group_q
|
||||
if before_str:
|
||||
before_q = parse_expression(before_str)
|
||||
result = (before_q | result) if before_op == '|' else (before_q & result)
|
||||
if after_str:
|
||||
after_q = parse_expression(after_str)
|
||||
result = (result | after_q) if after_op == '|' else (result & after_q)
|
||||
return result
|
||||
|
||||
tokens = []
|
||||
operators = []
|
||||
remaining = expr
|
||||
|
||||
while remaining:
|
||||
upper = remaining.upper()
|
||||
and_pos = upper.find(' AND ')
|
||||
or_pos = upper.find(' OR ')
|
||||
|
||||
if and_pos == -1 and or_pos == -1:
|
||||
tokens.append(remaining.strip())
|
||||
break
|
||||
|
||||
if and_pos == -1:
|
||||
pos, op, op_len = or_pos, '|', 4
|
||||
elif or_pos == -1:
|
||||
pos, op, op_len = and_pos, '&', 5
|
||||
elif and_pos < or_pos:
|
||||
pos, op, op_len = and_pos, '&', 5
|
||||
else:
|
||||
pos, op, op_len = or_pos, '|', 4
|
||||
|
||||
token = remaining[:pos].strip()
|
||||
if token:
|
||||
tokens.append(token)
|
||||
operators.append(op)
|
||||
remaining = remaining[pos + op_len:]
|
||||
|
||||
if not tokens:
|
||||
return Q()
|
||||
|
||||
result = build_q(tokens[0])
|
||||
for i, op in enumerate(operators):
|
||||
next_q = build_q(tokens[i + 1])
|
||||
result = (result & next_q) if op == '&' else (result | next_q)
|
||||
return result
|
||||
|
||||
return parse_expression(processed)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
from rest_framework import serializers
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
||||
class EPGSourceSerializer(serializers.ModelSerializer):
|
||||
epg_data_count = serializers.SerializerMethodField()
|
||||
|
|
@ -22,7 +22,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'name',
|
||||
'source_type',
|
||||
'url',
|
||||
'api_key',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
|
|
@ -36,6 +37,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'epg_data_count',
|
||||
'has_channels',
|
||||
]
|
||||
extra_kwargs = {'password': {'write_only': True}}
|
||||
|
||||
def get_epg_data_count(self, obj):
|
||||
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
|
||||
|
|
@ -66,6 +68,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
instance._cron_expression = cron_expr
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'password' and not value:
|
||||
continue
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
|
@ -142,6 +146,20 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
|||
previously_shown = cp.get('previously_shown_details') or {}
|
||||
data['original_air_date'] = previously_shown.get('start')
|
||||
|
||||
# Content advisory (SD)
|
||||
data['content_advisory'] = cp.get('content_advisory') or []
|
||||
|
||||
# Full content ratings array (SD — all regional ratings)
|
||||
data['content_ratings'] = cp.get('content_ratings') or []
|
||||
|
||||
# Sports event details (SD)
|
||||
data['event_details'] = cp.get('event_details')
|
||||
|
||||
# Runtime (duration without commercials)
|
||||
length = cp.get('length') or {}
|
||||
data['runtime'] = length.get('value') if length else None
|
||||
data['runtime_units'] = length.get('units') if length else None
|
||||
|
||||
# External IDs
|
||||
data['imdb_id'] = cp.get('imdb.com_id')
|
||||
data['tmdb_id'] = cp.get('themoviedb.org_id')
|
||||
|
|
@ -151,6 +169,17 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
|||
data['icon'] = cp.get('icon')
|
||||
data['images'] = cp.get('images') or []
|
||||
|
||||
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth
|
||||
if cp.get('sd_icon'):
|
||||
poster_path = f"/api/epg/programs/{obj.id}/poster/"
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
|
||||
else:
|
||||
data['poster_url'] = poster_path
|
||||
else:
|
||||
data['poster_url'] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -170,3 +199,72 @@ class EPGDataSerializer(serializers.ModelSerializer):
|
|||
'icon_url',
|
||||
'epg_source',
|
||||
]
|
||||
|
||||
|
||||
class ProgramSearchChannelSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight channel info for search results."""
|
||||
channel_group = serializers.CharField(source='channel_group.name', default=None)
|
||||
|
||||
class Meta:
|
||||
model = Channel
|
||||
fields = ['id', 'name', 'channel_number', 'channel_group', 'tvg_id']
|
||||
|
||||
|
||||
class ProgramSearchStreamSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight stream info for search results."""
|
||||
channel_group = serializers.CharField(source='channel_group.name', default=None)
|
||||
m3u_account = serializers.CharField(source='m3u_account.name', default=None)
|
||||
|
||||
class Meta:
|
||||
model = Stream
|
||||
fields = ['id', 'name', 'channel_group', 'tvg_id', 'm3u_account']
|
||||
|
||||
|
||||
class ProgramSearchResultSerializer(serializers.ModelSerializer):
|
||||
"""Full program data with associated channels and streams for search results."""
|
||||
epg_source = serializers.CharField(source='epg.epg_source.name', default=None)
|
||||
epg_name = serializers.CharField(source='epg.name', default=None)
|
||||
epg_icon_url = serializers.URLField(source='epg.icon_url', default=None)
|
||||
channels = serializers.SerializerMethodField()
|
||||
streams = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProgramData
|
||||
fields = [
|
||||
'id', 'title', 'sub_title', 'description',
|
||||
'start_time', 'end_time', 'tvg_id', 'custom_properties',
|
||||
'epg_source', 'epg_name', 'epg_icon_url',
|
||||
'channels', 'streams',
|
||||
]
|
||||
|
||||
def _accessible_channels(self, obj):
|
||||
"""Return prefetched channels filtered to those the requesting user can access."""
|
||||
channels = list(obj.epg.channels.all()) if obj.epg else []
|
||||
user = self.context.get('user')
|
||||
if user is None or user.user_level >= 10:
|
||||
return channels
|
||||
custom_props = user.custom_properties or {}
|
||||
hide_adult = custom_props.get('hide_adult_content', False)
|
||||
return [
|
||||
ch for ch in channels
|
||||
if ch.user_level <= user.user_level and (not hide_adult or not ch.is_adult)
|
||||
]
|
||||
|
||||
def get_channels(self, obj):
|
||||
fields = self.context.get('fields')
|
||||
if fields is not None and 'channels' not in fields:
|
||||
return []
|
||||
return ProgramSearchChannelSerializer(self._accessible_channels(obj), many=True).data
|
||||
|
||||
def get_streams(self, obj):
|
||||
fields = self.context.get('fields')
|
||||
if fields is not None and 'streams' not in fields:
|
||||
return []
|
||||
stream_ids = set()
|
||||
streams = []
|
||||
for ch in self._accessible_channels(obj):
|
||||
for s in ch.streams.all():
|
||||
if s.id not in stream_ids:
|
||||
stream_ids.add(s.id)
|
||||
streams.append(s)
|
||||
return ProgramSearchStreamSerializer(streams, many=True).data
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from .models import EPGSource, EPGData
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id, fetch_schedules_direct_stations
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.utils import is_protected_path, send_websocket_update
|
||||
import json
|
||||
|
|
@ -12,9 +12,16 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
|
||||
# Trigger refresh only if the source is newly created, active, and not a dummy EPG
|
||||
# Trigger refresh only if the source is newly created, active, and not a dummy EPG.
|
||||
# For Schedules Direct sources, run a stations-only fetch on creation so the user
|
||||
# can run Auto-match EPG before committing to a full schedule/program fetch.
|
||||
# A short countdown gives the frontend time to receive the API response and
|
||||
# populate the store before WebSocket updates arrive.
|
||||
if created and instance.is_active and instance.source_type != 'dummy':
|
||||
refresh_epg_data.delay(instance.id)
|
||||
if instance.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct_stations.apply_async((instance.id,), countdown=3)
|
||||
else:
|
||||
refresh_epg_data.delay(instance.id)
|
||||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def create_dummy_epg_data(sender, instance, created, **kwargs):
|
||||
|
|
|
|||
3103
apps/epg/tasks.py
3103
apps/epg/tasks.py
File diff suppressed because it is too large
Load diff
29
apps/epg/tests/fixtures/test_epg.xml
vendored
Normal file
29
apps/epg/tests/fixtures/test_epg.xml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv generator-info-name="test">
|
||||
<channel id="channel.current">
|
||||
<display-name>Current Channel</display-name>
|
||||
</channel>
|
||||
<channel id="channel.past">
|
||||
<display-name>Past Channel</display-name>
|
||||
</channel>
|
||||
<channel id="channel.empty">
|
||||
<display-name>Empty Channel</display-name>
|
||||
</channel>
|
||||
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.past">
|
||||
<title>Past Show</title>
|
||||
<desc>A show that already ended</desc>
|
||||
</programme>
|
||||
<programme start="20200101060000 +0000" stop="20200101120000 +0000" channel="channel.past">
|
||||
<title>Another Past Show</title>
|
||||
<desc>Another show that ended</desc>
|
||||
</programme>
|
||||
<programme start="20000101000000 +0000" stop="20991231235959 +0000" channel="channel.current">
|
||||
<title>Always On Show</title>
|
||||
<sub-title>The eternal broadcast</sub-title>
|
||||
<desc>This programme spans a very long time for testing</desc>
|
||||
</programme>
|
||||
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.current">
|
||||
<title>Old Current Show</title>
|
||||
<desc>An old show on current channel</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
194
apps/epg/tests/test_current_programs_api.py
Normal file
194
apps/epg/tests/test_current_programs_api.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
from apps.epg.models import EPGSource, EPGData, ProgramData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
CURRENT_PROGRAMS_URL = "/api/epg/current-programs/"
|
||||
|
||||
|
||||
class CurrentProgramsAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", password="testpass123"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.now = timezone.now()
|
||||
|
||||
# Create an XMLTV source with programmes
|
||||
self.source = EPGSource.objects.create(
|
||||
name="Test XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
self.epg_data = EPGData.objects.create(
|
||||
tvg_id="test.channel",
|
||||
name="Test Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.program = ProgramData.objects.create(
|
||||
epg=self.epg_data,
|
||||
start_time=self.now - timezone.timedelta(hours=1),
|
||||
end_time=self.now + timezone.timedelta(hours=1),
|
||||
title="Current Show",
|
||||
description="A show currently airing",
|
||||
tvg_id="test.channel",
|
||||
)
|
||||
|
||||
# Dummy EPG source
|
||||
self.dummy_source = EPGSource.objects.create(
|
||||
name="Dummy EPG",
|
||||
source_type="dummy",
|
||||
)
|
||||
self.dummy_epg = EPGData.objects.create(
|
||||
tvg_id="dummy.channel",
|
||||
name="Dummy Channel",
|
||||
epg_source=self.dummy_source,
|
||||
)
|
||||
|
||||
def test_returns_program_for_current_time_window(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["title"], "Current Show")
|
||||
self.assertEqual(response.data[0]["epg_data_id"], self.epg_data.id)
|
||||
|
||||
def test_program_payload_has_expected_fields(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
payload = response.data[0]
|
||||
expected_keys = {
|
||||
"id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"title",
|
||||
"description",
|
||||
"sub_title",
|
||||
"tvg_id",
|
||||
"epg_data_id",
|
||||
}
|
||||
self.assertTrue(expected_keys.issubset(set(payload.keys())))
|
||||
self.assertEqual(payload["epg_data_id"], self.epg_data.id)
|
||||
|
||||
@patch("apps.epg.api_views.find_current_program_for_tvg_id", return_value=None)
|
||||
def test_returns_empty_when_no_program_matches(self, mock_find):
|
||||
# Create EPG data with no DB programme and fallback returns None
|
||||
epg_no_prog = EPGData.objects.create(
|
||||
tvg_id="no.programme",
|
||||
name="No Programme Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [epg_no_prog.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
@patch(
|
||||
"apps.epg.api_views.find_current_program_for_tvg_id",
|
||||
return_value="timeout",
|
||||
)
|
||||
def test_returns_parsing_sentinel_on_timeout(self, mock_find):
|
||||
epg_no_prog = EPGData.objects.create(
|
||||
tvg_id="timeout.channel",
|
||||
name="Timeout Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [epg_no_prog.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertTrue(response.data[0]["parsing"])
|
||||
self.assertEqual(response.data[0]["epg_data_id"], epg_no_prog.id)
|
||||
|
||||
def test_400_when_both_channel_uuids_and_epg_data_ids(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"channel_uuids": ["abc"], "epg_data_ids": [1]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("not both", response.data["error"])
|
||||
|
||||
def test_skips_dummy_epg_sources(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.dummy_epg.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_enforces_50_id_limit(self):
|
||||
# Create 55 EPG entries, each with a current programme so DB lookup
|
||||
# handles them all (no fallback to find_current_program_for_tvg_id).
|
||||
ids = []
|
||||
for i in range(55):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=f"limit.{i}",
|
||||
name=f"Limit Channel {i}",
|
||||
epg_source=self.source,
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg,
|
||||
start_time=self.now - timezone.timedelta(hours=1),
|
||||
end_time=self.now + timezone.timedelta(hours=1),
|
||||
title=f"Show {i}",
|
||||
tvg_id=f"limit.{i}",
|
||||
)
|
||||
ids.append(epg.id)
|
||||
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": ids},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# The view truncates to 50 IDs, so at most 50 results
|
||||
self.assertLessEqual(len(response.data), 50)
|
||||
|
||||
def test_400_for_non_integer_epg_data_ids(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": ["abc", "def"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("valid integers", response.data["error"])
|
||||
|
||||
def test_auth_required(self):
|
||||
anon_client = APIClient()
|
||||
response = anon_client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertIn(
|
||||
response.status_code,
|
||||
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||
)
|
||||
98
apps/epg/tests/test_epg_import_api.py
Normal file
98
apps/epg/tests/test_epg_import_api.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.epg.models import EPGSource, EPGSourceIndex
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
IMPORT_URL = "/api/epg/import/"
|
||||
|
||||
|
||||
class EPGImportAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="epg_import_admin", password="testpass123"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_dummy_source_rejected_without_dispatch(self, mock_delay):
|
||||
source = EPGSource.objects.create(
|
||||
name="Dummy EPG",
|
||||
source_type="dummy",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
IMPORT_URL, {"id": source.id}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse(response.data["success"])
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_xmltv_dispatches_without_loading_programme_index(
|
||||
self, mock_delay
|
||||
):
|
||||
source = EPGSource.objects.create(
|
||||
name="Large Index XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
EPGSourceIndex.objects.create(
|
||||
source=source,
|
||||
data={
|
||||
"channels": {f"ch.{i}": {"offsets": [0, 100]} for i in range(200)},
|
||||
"interleaved_channels": [],
|
||||
},
|
||||
)
|
||||
mock_delay.reset_mock()
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = self.client.post(
|
||||
IMPORT_URL, {"id": source.id}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertTrue(response.data["success"])
|
||||
mock_delay.assert_called_once_with(source.id, force=False)
|
||||
for query in ctx.captured_queries:
|
||||
self.assertNotIn(
|
||||
"programme_index",
|
||||
query["sql"].lower(),
|
||||
"import trigger should not read programme_index",
|
||||
)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_missing_source_still_dispatches(self, mock_delay):
|
||||
response = self.client.post(IMPORT_URL, {"id": 99999}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
mock_delay.assert_called_once_with(99999, force=False)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_honours_force_flag(self, mock_delay):
|
||||
source = EPGSource.objects.create(
|
||||
name="Force XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
mock_delay.reset_mock()
|
||||
|
||||
response = self.client.post(
|
||||
IMPORT_URL,
|
||||
{"id": source.id, "force": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
mock_delay.assert_called_once_with(source.id, force=True)
|
||||
339
apps/epg/tests/test_epg_search_api.py
Normal file
339
apps/epg/tests/test_epg_search_api.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.epg.models import EPGData, EPGSource, ProgramData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SEARCH_URL = "/api/epg/programs/search/"
|
||||
|
||||
|
||||
class ProgramSearchAPIViewTests(TestCase):
|
||||
"""Tests for the /api/epg/programs/search/ endpoint."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.epg_source = EPGSource.objects.create(name="Test Source", source_type="xmltv")
|
||||
cls.epg = EPGData.objects.create(
|
||||
tvg_id="test-tvg", name="Test EPG", epg_source=cls.epg_source
|
||||
)
|
||||
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
|
||||
# Premier League Football — airing now
|
||||
cls.prog_football = ProgramData.objects.create(
|
||||
epg=cls.epg,
|
||||
title="Premier League Football",
|
||||
description="Live coverage of the Premier League match.",
|
||||
start_time=now - timedelta(minutes=30),
|
||||
end_time=now + timedelta(hours=1),
|
||||
)
|
||||
|
||||
# Newcastle vs Villa — also airing now
|
||||
cls.prog_newcastle = ProgramData.objects.create(
|
||||
epg=cls.epg,
|
||||
title="Newcastle vs Villa",
|
||||
description="Match highlights.",
|
||||
start_time=now - timedelta(minutes=15),
|
||||
end_time=now + timedelta(hours=2),
|
||||
)
|
||||
|
||||
# BBC News — starts in 3 hours
|
||||
cls.prog_news = ProgramData.objects.create(
|
||||
epg=cls.epg,
|
||||
title="BBC News at Ten",
|
||||
description="The latest news from around the world.",
|
||||
start_time=now + timedelta(hours=3),
|
||||
end_time=now + timedelta(hours=4),
|
||||
)
|
||||
|
||||
# Nature Documentary — starts in 5 hours
|
||||
cls.prog_doc = ProgramData.objects.create(
|
||||
epg=cls.epg,
|
||||
title="Nature Documentary",
|
||||
description="Exploring wildlife in the Amazon.",
|
||||
start_time=now + timedelta(hours=5),
|
||||
end_time=now + timedelta(hours=6),
|
||||
)
|
||||
|
||||
cls.now = now
|
||||
cls.user = User.objects.create_user(username="testuser", password="pass", user_level=1)
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient(REMOTE_ADDR="127.0.0.1")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Response structure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_response_structure(self):
|
||||
"""Response includes pagination envelope and all expected program fields."""
|
||||
response = self.client.get(SEARCH_URL, {"page_size": 1})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn("count", data)
|
||||
self.assertIn("results", data)
|
||||
self.assertIn("next", data)
|
||||
self.assertIn("previous", data)
|
||||
|
||||
program = data["results"][0]
|
||||
for field in ("id", "title", "start_time", "end_time", "tvg_id", "channels", "streams"):
|
||||
self.assertIn(field, program)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# No filter — returns all programs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_filters_returns_all(self):
|
||||
"""Omitting filters returns all seeded programs."""
|
||||
response = self.client.get(SEARCH_URL)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.json()["count"], 4)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Title search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_title_simple_match(self):
|
||||
"""Simple title search returns matching programs."""
|
||||
response = self.client.get(SEARCH_URL, {"title": "football"})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["title"], "Premier League Football")
|
||||
|
||||
def test_title_multi_word_is_phrase_not_implicit_and(self):
|
||||
"""Space-separated words without AND/OR are matched as a phrase, not as implicit AND.
|
||||
|
||||
'Premier Football' contains both words from 'Premier League Football' but
|
||||
not as a consecutive phrase — so it should return 0 results.
|
||||
Use 'Premier AND Football' to match both words independently.
|
||||
"""
|
||||
phrase = self.client.get(SEARCH_URL, {"title": "Premier Football"}).json()
|
||||
explicit_and = self.client.get(SEARCH_URL, {"title": "Premier AND Football"}).json()
|
||||
# Phrase match: "Premier Football" is not a substring of "Premier League Football"
|
||||
self.assertEqual(phrase["count"], 0)
|
||||
# Explicit AND: both words present → matches
|
||||
self.assertEqual(explicit_and["count"], 1)
|
||||
|
||||
def test_title_quoted_phrase(self):
|
||||
"""Double-quoted phrases are matched literally; 'and'/'or' inside quotes are not operators.
|
||||
|
||||
This is the standard way to search for program titles that contain conjunctions,
|
||||
e.g. "Law and Order". Without quotes, lowercase 'and'/'or' are still treated as
|
||||
case-insensitive boolean operators — so quoting is the reliable way to do a phrase match.
|
||||
"""
|
||||
prog = ProgramData.objects.create(
|
||||
epg=self.epg,
|
||||
title="Law and Order",
|
||||
description="Crime drama.",
|
||||
start_time=self.now + timedelta(hours=10),
|
||||
end_time=self.now + timedelta(hours=11),
|
||||
)
|
||||
try:
|
||||
# Quoted phrase → exact substring match → finds the program
|
||||
quoted = self.client.get(SEARCH_URL, {"title": '"Law and Order"'}).json()
|
||||
self.assertEqual(quoted["count"], 1)
|
||||
self.assertEqual(quoted["results"][0]["title"], "Law and Order")
|
||||
|
||||
# Quoted phrase that is not a substring → no match
|
||||
non_phrase = self.client.get(SEARCH_URL, {"title": '"Law Order"'}).json()
|
||||
self.assertEqual(non_phrase["count"], 0)
|
||||
|
||||
# Mix: quoted phrase AND bare term present in the title → matches
|
||||
mixed_match = self.client.get(SEARCH_URL, {"title": '"Law and Order" AND order'}).json()
|
||||
self.assertEqual(mixed_match["count"], 1)
|
||||
|
||||
# Mix: quoted phrase AND bare term NOT in the title → no match
|
||||
mixed_no_match = self.client.get(SEARCH_URL, {"title": '"Law and Order" AND crime'}).json()
|
||||
self.assertEqual(mixed_no_match["count"], 0)
|
||||
finally:
|
||||
prog.delete()
|
||||
def test_title_case_insensitive(self):
|
||||
"""Title search is case-insensitive."""
|
||||
lower = self.client.get(SEARCH_URL, {"title": "football"}).json()
|
||||
upper = self.client.get(SEARCH_URL, {"title": "FOOTBALL"}).json()
|
||||
self.assertEqual(lower["count"], 1)
|
||||
self.assertEqual(upper["count"], 1)
|
||||
self.assertEqual(lower["results"][0]["title"], upper["results"][0]["title"])
|
||||
|
||||
def test_title_and_operator(self):
|
||||
"""AND operator (case-insensitive) requires both terms to be present in the title."""
|
||||
upper = self.client.get(SEARCH_URL, {"title": "Premier AND League"}).json()
|
||||
lower = self.client.get(SEARCH_URL, {"title": "Premier and League"}).json()
|
||||
self.assertEqual(upper["count"], 1)
|
||||
self.assertEqual(lower["count"], 1)
|
||||
self.assertIn("Premier", upper["results"][0]["title"])
|
||||
|
||||
def test_title_or_operator(self):
|
||||
"""OR operator (case-insensitive) returns programs matching either term."""
|
||||
upper = self.client.get(SEARCH_URL, {"title": "Newcastle OR Football"})
|
||||
lower = self.client.get(SEARCH_URL, {"title": "Newcastle or Football"})
|
||||
self.assertEqual(upper.status_code, status.HTTP_200_OK)
|
||||
for response in (upper, lower):
|
||||
titles = [r["title"] for r in response.json()["results"]]
|
||||
self.assertIn("Premier League Football", titles)
|
||||
self.assertIn("Newcastle vs Villa", titles)
|
||||
|
||||
def test_title_no_match_returns_empty(self):
|
||||
"""Search with no matching title returns empty results."""
|
||||
response = self.client.get(SEARCH_URL, {"title": "XYZNONEXISTENT999"})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 0)
|
||||
self.assertEqual(data["results"], [])
|
||||
|
||||
def test_title_whole_word_matching(self):
|
||||
"""title_whole_words=true matches complete words but not partial words."""
|
||||
# 'new' as substring matches 'Newcastle vs Villa' and 'BBC News at Ten'
|
||||
partial = self.client.get(SEARCH_URL, {"title": "new"}).json()
|
||||
# Whole-word \bnew\b matches neither 'Newcastle' nor 'News' (partial matches)
|
||||
whole_no_match = self.client.get(SEARCH_URL, {"title": "new", "title_whole_words": "true"}).json()
|
||||
self.assertEqual(partial["count"], 2)
|
||||
self.assertEqual(whole_no_match["count"], 0)
|
||||
|
||||
# 'football' is a complete word in 'Premier League Football' — must still match
|
||||
whole_match = self.client.get(SEARCH_URL, {"title": "football", "title_whole_words": "true"}).json()
|
||||
self.assertEqual(whole_match["count"], 1)
|
||||
self.assertEqual(whole_match["results"][0]["title"], "Premier League Football")
|
||||
|
||||
# 'league' is also a complete word — and 'Premier AND league' with whole_words works
|
||||
both_words = self.client.get(SEARCH_URL, {"title": "Premier AND league", "title_whole_words": "true"}).json()
|
||||
self.assertEqual(both_words["count"], 1)
|
||||
|
||||
def test_title_regex(self):
|
||||
"""title_regex=true applies the query as a regex pattern."""
|
||||
response = self.client.get(
|
||||
SEARCH_URL, {"title": "^Premier", "title_regex": "true"}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for program in response.json()["results"]:
|
||||
self.assertTrue(program["title"].startswith("Premier"))
|
||||
|
||||
def test_title_parenthetical_grouping(self):
|
||||
"""Parenthetical groups with AND/OR are evaluated correctly."""
|
||||
# (Newcastle OR Football) AND (Villa OR League) should match both seeded programs:
|
||||
# "Premier League Football" matches Football AND League
|
||||
# "Newcastle vs Villa" matches Newcastle AND Villa
|
||||
response = self.client.get(
|
||||
SEARCH_URL, {"title": "(Newcastle OR Football) AND (Villa OR League)"}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
titles = {r["title"] for r in response.json()["results"]}
|
||||
self.assertIn("Premier League Football", titles)
|
||||
self.assertIn("Newcastle vs Villa", titles)
|
||||
self.assertNotIn("BBC News at Ten", titles)
|
||||
self.assertNotIn("Nature Documentary", titles)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_description_simple_match(self):
|
||||
"""Description search returns programs whose description contains the term."""
|
||||
response = self.client.get(SEARCH_URL, {"description": "Premier League"})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["title"], "Premier League Football")
|
||||
|
||||
def test_description_and_operator(self):
|
||||
"""AND operator in description requires both terms."""
|
||||
response = self.client.get(SEARCH_URL, {"description": "latest AND news"})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["results"][0]["title"], "BBC News at Ten")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Time filters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_airing_at_returns_current_programs(self):
|
||||
"""airing_at returns programs where start_time <= t < end_time."""
|
||||
ts = self.now.isoformat()
|
||||
response = self.client.get(SEARCH_URL, {"airing_at": ts})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
titles = [r["title"] for r in response.json()["results"]]
|
||||
self.assertIn("Premier League Football", titles)
|
||||
self.assertIn("Newcastle vs Villa", titles)
|
||||
self.assertNotIn("BBC News at Ten", titles)
|
||||
|
||||
def test_start_after_filter(self):
|
||||
"""start_after excludes programs that start before the given time."""
|
||||
cutoff = (self.now + timedelta(hours=4)).isoformat()
|
||||
response = self.client.get(SEARCH_URL, {"start_after": cutoff})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
titles = [r["title"] for r in response.json()["results"]]
|
||||
self.assertIn("Nature Documentary", titles)
|
||||
self.assertNotIn("Premier League Football", titles)
|
||||
self.assertNotIn("BBC News at Ten", titles)
|
||||
|
||||
def test_start_before_filter(self):
|
||||
"""start_before excludes programs that start after the given time."""
|
||||
cutoff = (self.now + timedelta(hours=1)).isoformat()
|
||||
response = self.client.get(SEARCH_URL, {"start_before": cutoff})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
titles = [r["title"] for r in response.json()["results"]]
|
||||
self.assertIn("Premier League Football", titles)
|
||||
self.assertIn("Newcastle vs Villa", titles)
|
||||
self.assertNotIn("Nature Documentary", titles)
|
||||
|
||||
def test_invalid_datetime_returns_400(self):
|
||||
"""An unparseable datetime value returns a 400 error."""
|
||||
response = self.client.get(SEARCH_URL, {"airing_at": "not-a-date"})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("error", response.json())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Field selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_field_selection_limits_response_keys(self):
|
||||
"""fields param restricts the keys present in each result."""
|
||||
response = self.client.get(SEARCH_URL, {"fields": "title,start_time,end_time"})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for program in response.json()["results"]:
|
||||
self.assertIn("title", program)
|
||||
self.assertIn("start_time", program)
|
||||
self.assertIn("end_time", program)
|
||||
self.assertNotIn("description", program)
|
||||
self.assertNotIn("channels", program)
|
||||
self.assertNotIn("streams", program)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pagination
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_pagination_page_size(self):
|
||||
"""page_size limits the number of results returned."""
|
||||
response = self.client.get(SEARCH_URL, {"page_size": 2})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data["results"]), 2)
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertIsNotNone(data["next"])
|
||||
|
||||
def test_pagination_second_page(self):
|
||||
"""Page 2 returns different results from page 1."""
|
||||
page1 = self.client.get(SEARCH_URL, {"page": 1, "page_size": 2}).json()
|
||||
page2 = self.client.get(SEARCH_URL, {"page": 2, "page_size": 2}).json()
|
||||
ids_p1 = {r["id"] for r in page1["results"]}
|
||||
ids_p2 = {r["id"] for r in page2["results"]}
|
||||
self.assertTrue(ids_p1.isdisjoint(ids_p2))
|
||||
|
||||
def test_page_size_capped_at_maximum(self):
|
||||
"""page_size beyond the 500 maximum is clamped, not rejected."""
|
||||
response = self.client.get(SEARCH_URL, {"page_size": 10000})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# All 4 seeded programs are returned — request was accepted and clamped
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertEqual(len(data["results"]), 4)
|
||||
238
apps/epg/tests/test_parse_programs_for_source.py
Normal file
238
apps/epg/tests/test_parse_programs_for_source.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.db import connection, transaction
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import Channel
|
||||
from apps.epg.models import EPGSource, EPGData, ProgramData
|
||||
from apps.epg.tasks import (
|
||||
parse_programs_for_source,
|
||||
_flush_epg_program_staging_batch,
|
||||
_swap_staged_epg_programs,
|
||||
_EPG_PARSE_BATCH_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def _programme_xml(channel_id, title, start, stop):
|
||||
return (
|
||||
f' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
|
||||
f' <title>{title}</title>\n'
|
||||
f' </programme>\n'
|
||||
)
|
||||
|
||||
|
||||
def _xmltv_file(programmes):
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<tv generator-info-name="test">\n'
|
||||
f'{programmes}'
|
||||
'</tv>\n'
|
||||
)
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.xml',
|
||||
delete=False,
|
||||
encoding='utf-8',
|
||||
)
|
||||
handle.write(body)
|
||||
handle.close()
|
||||
return handle.name
|
||||
|
||||
|
||||
class ParseProgramsForSourceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XMLTV Parse Test',
|
||||
source_type='xmltv',
|
||||
)
|
||||
self.mapped_epg = EPGData.objects.create(
|
||||
epg_source=self.source,
|
||||
tvg_id='mapped.channel',
|
||||
name='Mapped Channel',
|
||||
)
|
||||
self.unmapped_epg = EPGData.objects.create(
|
||||
epg_source=self.source,
|
||||
tvg_id='unmapped.channel',
|
||||
name='Unmapped Channel',
|
||||
)
|
||||
Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped Channel',
|
||||
epg_data=self.mapped_epg,
|
||||
)
|
||||
self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||
self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000')
|
||||
self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000')
|
||||
|
||||
def tearDown(self):
|
||||
if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path):
|
||||
os.unlink(self.xml_path)
|
||||
|
||||
def _configure_source_file(self, programmes):
|
||||
self.xml_path = _xmltv_file(programmes)
|
||||
self.source.file_path = self.xml_path
|
||||
self.source.save(update_fields=['file_path'])
|
||||
|
||||
@patch('apps.epg.tasks.log_system_event')
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
def test_replaces_programs_for_mapped_channels(self, _send_update, _log_event):
|
||||
old_start = self.base_time - timedelta(days=1)
|
||||
ProgramData.objects.create(
|
||||
epg=self.mapped_epg,
|
||||
start_time=old_start,
|
||||
end_time=old_start + timedelta(hours=1),
|
||||
title='Old Programme',
|
||||
tvg_id=self.mapped_epg.tvg_id,
|
||||
)
|
||||
orphan_start = self.base_time - timedelta(days=1)
|
||||
ProgramData.objects.create(
|
||||
epg=self.unmapped_epg,
|
||||
start_time=orphan_start,
|
||||
end_time=orphan_start + timedelta(hours=1),
|
||||
title='Orphan Programme',
|
||||
tvg_id=self.unmapped_epg.tvg_id,
|
||||
)
|
||||
|
||||
programmes = (
|
||||
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
|
||||
+ _programme_xml('unmapped.channel', 'Skipped Show', self.start, self.stop)
|
||||
)
|
||||
self._configure_source_file(programmes)
|
||||
|
||||
result = parse_programs_for_source(self.source)
|
||||
|
||||
self.assertTrue(result)
|
||||
mapped_programs = ProgramData.objects.filter(epg=self.mapped_epg)
|
||||
self.assertEqual(mapped_programs.count(), 1)
|
||||
self.assertEqual(mapped_programs.get().title, 'New Show')
|
||||
self.assertFalse(ProgramData.objects.filter(epg=self.unmapped_epg).exists())
|
||||
|
||||
@patch('apps.epg.tasks.log_system_event')
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
def test_atomic_failure_rolls_back_and_preserves_existing_programs(self, _send_update, _log_event):
|
||||
old_start = self.base_time - timedelta(days=1)
|
||||
ProgramData.objects.create(
|
||||
epg=self.mapped_epg,
|
||||
start_time=old_start,
|
||||
end_time=old_start + timedelta(hours=1),
|
||||
title='Keep Me',
|
||||
tvg_id=self.mapped_epg.tvg_id,
|
||||
)
|
||||
|
||||
self._configure_source_file(
|
||||
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
|
||||
)
|
||||
|
||||
swap_path = (
|
||||
'apps.epg.tasks._swap_staged_epg_programs'
|
||||
if connection.vendor == 'postgresql'
|
||||
else 'apps.epg.tasks._swap_parsed_epg_programs'
|
||||
)
|
||||
with patch(swap_path, side_effect=RuntimeError('simulated insert failure')):
|
||||
result = parse_programs_for_source(self.source)
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), 1)
|
||||
self.assertEqual(
|
||||
ProgramData.objects.get(epg=self.mapped_epg).title,
|
||||
'Keep Me',
|
||||
)
|
||||
|
||||
@patch('apps.epg.tasks.log_system_event')
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
def test_streams_batches_without_holding_full_program_list(self, _send_update, _log_event):
|
||||
if connection.vendor != 'postgresql':
|
||||
self.skipTest('PostgreSQL staging batches are required for this assertion')
|
||||
|
||||
programme_count = _EPG_PARSE_BATCH_SIZE * 2
|
||||
programmes = ''.join(
|
||||
_programme_xml(
|
||||
'mapped.channel',
|
||||
f'Show {idx}',
|
||||
self.start,
|
||||
self.stop,
|
||||
)
|
||||
for idx in range(programme_count)
|
||||
)
|
||||
self._configure_source_file(programmes)
|
||||
flush_sizes = []
|
||||
original_flush = _flush_epg_program_staging_batch
|
||||
|
||||
def tracking_flush(batch):
|
||||
flush_sizes.append(len(batch))
|
||||
return original_flush(batch)
|
||||
|
||||
with patch('apps.epg.tasks._flush_epg_program_staging_batch', side_effect=tracking_flush):
|
||||
result = parse_programs_for_source(self.source)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), programme_count)
|
||||
self.assertEqual(sum(flush_sizes), programme_count)
|
||||
self.assertTrue(all(size <= _EPG_PARSE_BATCH_SIZE for size in flush_sizes))
|
||||
self.assertGreater(len(flush_sizes), 1)
|
||||
|
||||
@patch('apps.epg.tasks.log_system_event')
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
def test_live_programs_remain_until_swap_commits(self, _send_update, _log_event):
|
||||
if connection.vendor != 'postgresql':
|
||||
self.skipTest('PostgreSQL staging swap is required for this assertion')
|
||||
|
||||
old_start = self.base_time - timedelta(days=1)
|
||||
ProgramData.objects.create(
|
||||
epg=self.mapped_epg,
|
||||
start_time=old_start,
|
||||
end_time=old_start + timedelta(hours=1),
|
||||
title='Old Programme',
|
||||
tvg_id=self.mapped_epg.tvg_id,
|
||||
)
|
||||
self._configure_source_file(
|
||||
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
|
||||
)
|
||||
|
||||
observed_titles_at_swap = []
|
||||
|
||||
def swap_with_visibility_check(mapped_epg_ids, epg_source, *args, **kwargs):
|
||||
observed_titles_at_swap.append(
|
||||
ProgramData.objects.get(epg=self.mapped_epg).title
|
||||
)
|
||||
return _swap_staged_epg_programs(mapped_epg_ids, epg_source, *args, **kwargs)
|
||||
|
||||
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=swap_with_visibility_check):
|
||||
result = parse_programs_for_source(self.source)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(observed_titles_at_swap, ['Old Programme'])
|
||||
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'New Show')
|
||||
|
||||
@patch('apps.epg.tasks.log_system_event')
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
def test_swap_delete_is_rolled_back_when_insert_fails(self, _send_update, _log_event):
|
||||
if connection.vendor != 'postgresql':
|
||||
self.skipTest('PostgreSQL staging swap is required for this assertion')
|
||||
|
||||
old_start = self.base_time - timedelta(days=1)
|
||||
ProgramData.objects.create(
|
||||
epg=self.mapped_epg,
|
||||
start_time=old_start,
|
||||
end_time=old_start + timedelta(hours=1),
|
||||
title='Keep Me',
|
||||
tvg_id=self.mapped_epg.tvg_id,
|
||||
)
|
||||
self._configure_source_file(
|
||||
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
|
||||
)
|
||||
|
||||
def failing_swap(mapped_epg_ids, epg_source, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()
|
||||
raise RuntimeError('simulated insert failure')
|
||||
|
||||
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=failing_swap):
|
||||
result = parse_programs_for_source(self.source)
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'Keep Me')
|
||||
753
apps/epg/tests/test_programme_index.py
Normal file
753
apps/epg/tests/test_programme_index.py
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
import os
|
||||
import gzip
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epg.models import EPGSource, EPGData
|
||||
from apps.epg.tasks import (
|
||||
find_current_program_for_tvg_id,
|
||||
build_programme_index,
|
||||
build_programme_index_task,
|
||||
)
|
||||
|
||||
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
FIXTURE_XML = os.path.join(FIXTURE_DIR, "test_epg.xml")
|
||||
|
||||
|
||||
class FindCurrentProgramTests(TestCase):
|
||||
def setUp(self):
|
||||
self.now = timezone.now()
|
||||
self.source = EPGSource.objects.create(
|
||||
name="Test Source",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
file_path=FIXTURE_XML,
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="channel.current",
|
||||
name="Current Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
|
||||
def test_returns_none_for_dummy_source(self):
|
||||
dummy = EPGSource.objects.create(name="Dummy", source_type="dummy")
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="x", name="X", epg_source=dummy
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_for_schedules_direct_source(self):
|
||||
sd = EPGSource.objects.create(
|
||||
name="SD", source_type="schedules_direct"
|
||||
)
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="x", name="X", epg_source=sd
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_when_tvg_id_empty(self):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="", name="Empty", epg_source=self.source
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_when_tvg_id_none(self):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=None, name="None", epg_source=self.source
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_byte_offset_index_hit(self):
|
||||
# Build the index from the fixture
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
self.assertIsNotNone(self.source.programme_index)
|
||||
|
||||
# "Always On Show" spans 2000-2099, so should always be current
|
||||
result = find_current_program_for_tvg_id(self.epg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Always On Show")
|
||||
self.assertEqual(result["sub_title"], "The eternal broadcast")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
"This programme spans a very long time for testing",
|
||||
)
|
||||
self.assertIn("start_time", result)
|
||||
self.assertIn("end_time", result)
|
||||
|
||||
def test_byte_offset_index_miss(self):
|
||||
# Build index, then query for a tvg_id that exists in the index
|
||||
# but has no programme airing now
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
|
||||
epg_past = EPGData.objects.create(
|
||||
tvg_id="channel.past",
|
||||
name="Past Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_past)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_index_miss_tvg_id_not_in_index(self):
|
||||
# tvg_id not in index at all
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
|
||||
epg_unknown = EPGData.objects.create(
|
||||
tvg_id="channel.nonexistent",
|
||||
name="Nonexistent",
|
||||
epg_source=self.source,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_unknown)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_accepts_integer_id(self):
|
||||
# find_current_program_for_tvg_id accepts an int (EPGData PK)
|
||||
build_programme_index(self.source.id)
|
||||
result = find_current_program_for_tvg_id(self.epg.id)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Always On Show")
|
||||
|
||||
def test_returns_none_for_nonexistent_id(self):
|
||||
result = find_current_program_for_tvg_id(99999)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_multi_block_file(self):
|
||||
# Create an XML where programmes for the same channel appear in
|
||||
# multiple non-contiguous blocks (A, B, A, B pattern).
|
||||
# The index records multiple offsets per channel so the lookup
|
||||
# scans all blocks.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A"/>\n'
|
||||
' <channel id="B"/>\n'
|
||||
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="A">\n'
|
||||
" <title>A Morning</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="B">\n'
|
||||
" <title>B Morning</title>\n"
|
||||
" </programme>\n"
|
||||
# Second block for A — current programme lives here
|
||||
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="A">\n'
|
||||
" <title>A Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="B">\n'
|
||||
" <title>B Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="MultiBlock",
|
||||
source_type="xmltv",
|
||||
file_path=tmp_path,
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIsNotNone(src.programme_index)
|
||||
|
||||
epg_a = EPGData.objects.create(
|
||||
tvg_id="A", name="A", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_a)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "A Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_channel_id_entities_and_whitespace_match_tvg_id(self):
|
||||
# programme@channel carries an XML entity and surrounding whitespace;
|
||||
# EPGData.tvg_id holds the lxml-decoded, stripped form. The index key
|
||||
# and lookup must canonicalize to the same value.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A&E.us"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel=" A&E.us ">\n'
|
||||
" <title>A and E Now</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("A&E.us", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="A&E.us", name="A&E", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "A and E Now")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_resolves_named_html_entities_in_programme_text(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="entity.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="entity.channel">\n'
|
||||
" <title>Café Live</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Named Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="entity.channel", name="Entity Channel", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Caf\u00e9 Live")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_fr_style_programme_with_channel_first_and_apostrophe_entities(self):
|
||||
# Based on epgshare01 FR feeds: channel is the first programme attr,
|
||||
# ids/text include non-ASCII plus XML entities, and sub-title is common.
|
||||
tvg_id = "France.3.-.C\u00f4te.d'Azur.fr"
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
'<tv generator-info-name="none" generator-info-url="none">\n'
|
||||
' <channel id="France.3.-.C\u00f4te.d'Azur.fr">\n'
|
||||
' <display-name lang="fr">France 3 - C\u00f4te d'Azur</display-name>\n'
|
||||
" </channel>\n"
|
||||
' <programme channel="France.3.-.C\u00f4te.d'Azur.fr" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title lang=\"fr\">La p'tite librairie</title>\n"
|
||||
" <sub-title lang=\"fr\">Le Lys de Brooklyn, de Betty Smith</sub-title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare FR", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn(tvg_id, src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=tvg_id, name="France 3 Cote d'Azur", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "La p'tite librairie")
|
||||
self.assertEqual(
|
||||
result["sub_title"], "Le Lys de Brooklyn, de Betty Smith"
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_fr_style_description_decodes_predefined_entities(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="Chasse.et.P\u00eache.fr"/>\n'
|
||||
' <programme channel="Chasse.et.P\u00eache.fr" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title lang=\"fr\">Chasse & p\u00eache, le mag</title>\n"
|
||||
" <desc lang=\"fr\">Au sommaire : "La r\u00e9gion" <HD> & bonus.</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare FR Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="Chasse.et.P\u00eache.fr",
|
||||
name="Chasse et Peche",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Chasse & p\u00eache, le mag")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
'Au sommaire : "La r\u00e9gion" <HD> & bonus.',
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_all_sources_style_start_stop_before_channel(self):
|
||||
# The all-sources EPG contains both programme attr orders:
|
||||
# channel/start/stop and start/stop/channel.
|
||||
tvg_id = "Atfal.&.Mawaheb.ae"
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="Atfal.&.Mawaheb.ae"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="Atfal.&.Mawaheb.ae">\n'
|
||||
" <title lang=\"en\">Kids & Talent</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare All Sources", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn(tvg_id, src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=tvg_id, name="Atfal and Mawaheb", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Kids & Talent")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_jesmann_fullguide_style_numeric_channel_id(self):
|
||||
# FullGuide.xml.gz uses numeric channel ids and consistently orders
|
||||
# programme attrs as start/stop/channel.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="123958">\n'
|
||||
" <display-name>Sample Numeric Channel</display-name>\n"
|
||||
" </channel>\n"
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="123958">\n'
|
||||
" <title>Breaking Basics</title>\n"
|
||||
" <desc>Tobi visits the "Flying Steps" in Berlin.</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Jesmann FullGuide", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("123958", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="123958", name="Numeric Channel", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Breaking Basics")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
'Tobi visits the "Flying Steps" in Berlin.',
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_accepts_single_quoted_channel_attribute(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
" <channel id='single.quote.channel'/>\n"
|
||||
" <programme start='20000101000000 +0000' "
|
||||
"stop='20991231235959 +0000' channel='single.quote.channel'>\n"
|
||||
" <title>Single Quote Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Single Quotes", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("single.quote.channel", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="single.quote.channel",
|
||||
name="Single Quote Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Single Quote Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_resolves_html_named_entity_not_predefined_by_xml(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="html.entity.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="html.entity.channel">\n'
|
||||
" <title>Café Society</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="HTML Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="html.entity.channel",
|
||||
name="HTML Entity Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Caf\u00e9\u00a0Society")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_handles_programme_element_larger_than_read_chunk(self):
|
||||
long_desc = "x" * (2 * 1024 * 1024 + 1024)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="large.programme.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="large.programme.channel">\n'
|
||||
" <title>Large Programme Current</title>\n"
|
||||
f" <desc>{long_desc}</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Large Programme", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="large.programme.channel",
|
||||
name="Large Programme Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Large Programme Current")
|
||||
self.assertEqual(len(result["description"]), len(long_desc))
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index_task")
|
||||
def test_no_index_dispatches_build_and_returns_timeout(self, mock_build_task):
|
||||
# Source with no index and file on disk
|
||||
src = EPGSource.objects.create(
|
||||
name="No Index",
|
||||
source_type="xmltv",
|
||||
file_path=FIXTURE_XML,
|
||||
)
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="channel.current",
|
||||
name="Current",
|
||||
epg_source=src,
|
||||
)
|
||||
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertEqual(result, "timeout")
|
||||
mock_build_task.delay.assert_called_once_with(src.id)
|
||||
|
||||
|
||||
class BuildProgrammeIndexTests(TestCase):
|
||||
def test_builds_index_from_fixture(self):
|
||||
source = EPGSource.objects.create(
|
||||
name="Index Test",
|
||||
source_type="xmltv",
|
||||
file_path=FIXTURE_XML,
|
||||
)
|
||||
build_programme_index(source.id)
|
||||
source.refresh_from_db()
|
||||
|
||||
index = source.programme_index
|
||||
self.assertIsNotNone(index)
|
||||
channels = index["channels"]
|
||||
self.assertIn("channel.current", channels)
|
||||
self.assertIn("channel.past", channels)
|
||||
# channel.empty has no programmes
|
||||
self.assertNotIn("channel.empty", channels)
|
||||
# Small fixture has no interleaved channels
|
||||
self.assertEqual(index["interleaved_channels"], [])
|
||||
|
||||
def test_builds_index_when_channel_attribute_has_valid_xml_spacing(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="spaced.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel = "spaced.channel">\n'
|
||||
" <title>Spaced Attribute Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Spaced Attribute", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn(
|
||||
"spaced.channel", src.programme_index["channels"]
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_builds_index_from_extracted_file_path_for_gz_source(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="gz.channel"/>\n'
|
||||
' <programme channel="gz.channel" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>GZ Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
gz_path = None
|
||||
xml_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb", suffix=".xml.gz", delete=False
|
||||
) as gz_file:
|
||||
gz_path = gz_file.name
|
||||
with gzip.GzipFile(fileobj=gz_file, mode="wb") as compressed:
|
||||
compressed.write(b"not the file the index should scan")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as xml_file:
|
||||
xml_file.write(xml)
|
||||
xml_path = xml_file.name
|
||||
|
||||
src = EPGSource.objects.create(
|
||||
name="Extracted GZ",
|
||||
source_type="xmltv",
|
||||
file_path=gz_path,
|
||||
extracted_file_path=xml_path,
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn("gz.channel", src.programme_index["channels"])
|
||||
finally:
|
||||
if gz_path:
|
||||
os.unlink(gz_path)
|
||||
if xml_path:
|
||||
os.unlink(xml_path)
|
||||
|
||||
def test_builds_index_ignores_elements_whose_name_only_starts_with_programme(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="real.channel"/>\n'
|
||||
' <programme-extra channel="not.a.programme" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>Not a Programme</title>\n"
|
||||
" </programme-extra>\n"
|
||||
' <programme channel="real.channel" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>Real Programme</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Programme Prefix", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn("real.channel", src.programme_index["channels"])
|
||||
self.assertNotIn("not.a.programme", src.programme_index["channels"])
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_nonexistent_source_does_not_raise(self):
|
||||
# Should log error but not raise
|
||||
build_programme_index(99999)
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index")
|
||||
def test_task_builds_and_releases_lock_when_free(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = True # lock acquired
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_build.assert_called_once_with(42)
|
||||
mock_redis.set.assert_called_once()
|
||||
self.assertEqual(
|
||||
mock_redis.set.call_args.args[0], "building_programme_index_42"
|
||||
)
|
||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index")
|
||||
def test_task_skips_when_lock_held(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = False # another build in flight
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_build.assert_not_called()
|
||||
mock_redis.delete.assert_not_called()
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
|
||||
def test_task_releases_lock_on_failure(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = True
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
with self.assertRaises(RuntimeError):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
||||
|
||||
def test_per_channel_interleaved_marking(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A"/>\n'
|
||||
' <channel id="B"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="A">\n'
|
||||
" <title>A Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="B">\n'
|
||||
" <title>B Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="19990101000000 +0000" '
|
||||
'stop="19990102000000 +0000" channel="A">\n'
|
||||
" <title>A Old</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Interleaved", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
with patch("apps.epg.tasks._OFFSET_CAP", 1):
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
index = src.programme_index
|
||||
self.assertEqual(index["interleaved_channels"], ["A"])
|
||||
|
||||
epg_b = EPGData.objects.create(
|
||||
tvg_id="B", name="B", epg_source=src
|
||||
)
|
||||
with patch(
|
||||
"apps.epg.tasks._scan_from_offset_for_tvg_id"
|
||||
) as mock_scan:
|
||||
result_b = find_current_program_for_tvg_id(epg_b)
|
||||
self.assertIsNotNone(result_b)
|
||||
self.assertEqual(result_b["title"], "B Current")
|
||||
mock_scan.assert_not_called()
|
||||
|
||||
epg_a = EPGData.objects.create(
|
||||
tvg_id="A", name="A", epg_source=src
|
||||
)
|
||||
result_a = find_current_program_for_tvg_id(epg_a)
|
||||
self.assertIsNotNone(result_a)
|
||||
self.assertEqual(result_a["title"], "A Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.epg.tasks import (
|
||||
_db_query_with_retry,
|
||||
_ensure_epg_refresh_terminal_status,
|
||||
_get_epg_source,
|
||||
_release_task_db_connection,
|
||||
refresh_epg_data,
|
||||
)
|
||||
|
||||
|
||||
class DbQueryWithRetryTests(SimpleTestCase):
|
||||
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||
|
||||
with patch(
|
||||
"apps.epg.tasks._release_task_db_connection"
|
||||
) as mock_release:
|
||||
result = _db_query_with_retry(fn, label="test query")
|
||||
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
mock_release.assert_called_once()
|
||||
|
||||
def test_raises_after_exhausting_retries(self):
|
||||
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||
|
||||
with patch("apps.epg.tasks._release_task_db_connection"):
|
||||
with self.assertRaises(IndexError):
|
||||
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
|
||||
|
||||
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||
@patch("apps.epg.tasks._ensure_epg_refresh_terminal_status")
|
||||
@patch("apps.epg.tasks._refresh_epg_data_impl")
|
||||
@patch("apps.epg.tasks.release_task_lock")
|
||||
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.epg.tasks.TaskLockRenewer")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_refresh_releases_db_connection_before_impl(
|
||||
self,
|
||||
mock_release,
|
||||
_mock_renewer,
|
||||
_mock_acquire,
|
||||
_mock_release_lock,
|
||||
mock_impl,
|
||||
_mock_ensure_terminal,
|
||||
):
|
||||
call_order = []
|
||||
|
||||
def track_release():
|
||||
call_order.append("release")
|
||||
|
||||
mock_release.side_effect = track_release
|
||||
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||
|
||||
result = refresh_epg_data(42)
|
||||
|
||||
self.assertEqual(result, "done")
|
||||
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||
|
||||
@patch("apps.epg.tasks.EPGSource")
|
||||
def test_get_epg_source_uses_retry_helper(self, mock_model):
|
||||
mock_source = MagicMock(id=42)
|
||||
mock_model.objects.get.return_value = mock_source
|
||||
|
||||
with patch("apps.epg.tasks._db_query_with_retry") as mock_retry:
|
||||
mock_retry.side_effect = lambda fn, **_: fn()
|
||||
source = _get_epg_source(42)
|
||||
|
||||
mock_retry.assert_called_once()
|
||||
mock_model.objects.get.assert_called_once_with(id=42)
|
||||
self.assertIs(source, mock_source)
|
||||
|
||||
|
||||
class EnsureEpgTerminalStatusTests(SimpleTestCase):
|
||||
@patch("apps.epg.tasks.send_epg_update")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_marks_stuck_fetching_as_error(self, _mock_release, mock_ws):
|
||||
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||
mock_model.STATUS_ERROR = "error"
|
||||
qs = MagicMock()
|
||||
mock_model.objects.filter.return_value = qs
|
||||
qs.values_list.return_value.first.return_value = "fetching"
|
||||
|
||||
_ensure_epg_refresh_terminal_status(7)
|
||||
|
||||
qs.update.assert_called_once()
|
||||
mock_ws.assert_called_once()
|
||||
|
||||
@patch("apps.epg.tasks.send_epg_update")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_leaves_success_unchanged(self, _mock_release, mock_ws):
|
||||
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||
qs = MagicMock()
|
||||
mock_model.objects.filter.return_value = qs
|
||||
qs.values_list.return_value.first.return_value = "success"
|
||||
|
||||
_ensure_epg_refresh_terminal_status(7)
|
||||
|
||||
qs.update.assert_not_called()
|
||||
mock_ws.assert_not_called()
|
||||
1357
apps/epg/tests/test_schedules_direct.py
Normal file
1357
apps/epg/tests/test_schedules_direct.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from apps.accounts.permissions import Authenticated, permission_classes_by_action
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
import logging
|
||||
|
|
@ -17,11 +18,23 @@ from django.views import View
|
|||
from django.utils.decorators import method_decorator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
from core.utils import build_absolute_uri_with_port
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hdhr_network_check(request):
|
||||
"""Return a 403 JsonResponse if the client IP is not allowed by the
|
||||
M3U_EPG network access policy. HDHR discovery endpoints expose channel
|
||||
inventory and stream URLs, so they share the same allowlist as M3U/EPG.
|
||||
"""
|
||||
if not network_access_allowed(request, "M3U_EPG"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
return None
|
||||
|
||||
|
||||
@login_required
|
||||
def hdhr_dashboard_view(request):
|
||||
"""Render the HDHR management page."""
|
||||
|
|
@ -46,28 +59,33 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
|
|||
# 🔹 2) Discover API
|
||||
class DiscoverAPIView(APIView):
|
||||
"""Returns device discovery information"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
description="Retrieve HDHomeRun device discovery information",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
uri_parts = ["hdhr"]
|
||||
if profile is not None:
|
||||
uri_parts.append(profile)
|
||||
def get(self, request, channel_profile=None, output_profile_id=None):
|
||||
blocked = _hdhr_network_check(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
|
||||
uri_parts = ["hdhr"]
|
||||
if channel_profile is not None:
|
||||
uri_parts.append(channel_profile)
|
||||
if output_profile_id is not None:
|
||||
uri_parts.append("output_profile")
|
||||
uri_parts.append(str(output_profile_id))
|
||||
|
||||
base_url = build_absolute_uri_with_port(request, f'/{"/".join(uri_parts)}/').rstrip("/")
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
# Calculate tuner count using centralized function
|
||||
from apps.m3u.utils import calculate_tuner_count
|
||||
tuner_count = calculate_tuner_count(minimum=1, unlimited_default=10)
|
||||
|
||||
# Create a unique DeviceID for the HDHomeRun device based on profile ID or a default value
|
||||
device_ID = "12345678" # Default DeviceID
|
||||
friendly_name = "Dispatcharr HDHomeRun"
|
||||
if profile is not None:
|
||||
device_ID = f"dispatcharr-hdhr-{profile}"
|
||||
friendly_name = f"Dispatcharr HDHomeRun - {profile}"
|
||||
slug_parts = [p for p in [channel_profile, str(output_profile_id) if output_profile_id is not None else None] if p]
|
||||
device_ID = f"dispatcharr-hdhr-{'-'.join(slug_parts)}" if slug_parts else "12345678"
|
||||
friendly_name = f"Dispatcharr HDHomeRun - {' / '.join(slug_parts)}" if slug_parts else "Dispatcharr HDHomeRun"
|
||||
|
||||
if not device:
|
||||
data = {
|
||||
"FriendlyName": friendly_name,
|
||||
|
|
@ -95,39 +113,81 @@ class DiscoverAPIView(APIView):
|
|||
return JsonResponse(data)
|
||||
|
||||
|
||||
def _resolve_hdhr_output_profile_id(output_profile_id):
|
||||
"""Return a validated output profile ID for HDHR lineup stream URLs.
|
||||
|
||||
Priority: URL path segment -> system default -> None (pass-through).
|
||||
"""
|
||||
from core.models import OutputProfile, CoreSettings
|
||||
candidate = output_profile_id if output_profile_id is not None else CoreSettings.get_hdhr_output_profile_id()
|
||||
if candidate is None:
|
||||
return None
|
||||
try:
|
||||
OutputProfile.objects.get(id=candidate, is_active=True)
|
||||
return candidate
|
||||
except OutputProfile.DoesNotExist:
|
||||
source = "URL" if output_profile_id is not None else "system default"
|
||||
logger.warning("HDHR output profile id=%s (%s) not found or inactive - serving without transcoding", candidate, source)
|
||||
return None
|
||||
|
||||
|
||||
# 🔹 3) Lineup API
|
||||
class LineupAPIView(APIView):
|
||||
"""Returns available channel lineup"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
description="Retrieve the available channel lineup",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
if profile is not None:
|
||||
channel_profile = ChannelProfile.objects.get(name=profile)
|
||||
channels = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=channel_profile,
|
||||
def get(self, request, channel_profile=None, output_profile_id=None):
|
||||
blocked = _hdhr_network_check(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
from apps.channels.managers import with_effective_values
|
||||
from apps.channels.utils import format_channel_number
|
||||
|
||||
if channel_profile is not None:
|
||||
try:
|
||||
cp = ChannelProfile.objects.get(name=channel_profile)
|
||||
except ChannelProfile.DoesNotExist:
|
||||
return JsonResponse([], safe=False)
|
||||
base_qs = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=cp,
|
||||
channelprofilemembership__enabled=True,
|
||||
).order_by("channel_number")
|
||||
)
|
||||
else:
|
||||
channels = Channel.objects.all().order_by("channel_number")
|
||||
base_qs = Channel.objects.all()
|
||||
|
||||
channels = (
|
||||
with_effective_values(base_qs)
|
||||
.exclude(hidden_from_output=True)
|
||||
.order_by("effective_channel_number")
|
||||
)
|
||||
|
||||
resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id)
|
||||
|
||||
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
|
||||
_output_profile_qs = (
|
||||
f"?output_profile={resolved_output_profile_id}"
|
||||
if resolved_output_profile_id is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
lineup = []
|
||||
for ch in channels:
|
||||
# Format channel number as integer if it has no decimal component
|
||||
if ch.channel_number is not None:
|
||||
if ch.channel_number == int(ch.channel_number):
|
||||
formatted_channel_number = str(int(ch.channel_number))
|
||||
else:
|
||||
formatted_channel_number = str(ch.channel_number)
|
||||
else:
|
||||
formatted_channel_number = ""
|
||||
formatted = format_channel_number(ch.effective_channel_number, empty=None)
|
||||
if formatted is None:
|
||||
continue
|
||||
formatted_channel_number = str(formatted)
|
||||
|
||||
stream_url = f"{_stream_url_prefix}{ch.uuid}{_output_profile_qs}"
|
||||
|
||||
lineup.append(
|
||||
{
|
||||
"GuideNumber": formatted_channel_number,
|
||||
"GuideName": ch.name,
|
||||
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
|
||||
"GuideName": ch.effective_name,
|
||||
"URL": stream_url,
|
||||
"Guide_ID": formatted_channel_number,
|
||||
"Station": formatted_channel_number,
|
||||
}
|
||||
|
|
@ -138,11 +198,16 @@ class LineupAPIView(APIView):
|
|||
# 🔹 4) Lineup Status API
|
||||
class LineupStatusAPIView(APIView):
|
||||
"""Returns the current status of the HDHR lineup"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun lineup status",
|
||||
)
|
||||
def get(self, request, profile=None):
|
||||
def get(self, request, channel_profile=None, output_profile_id=None):
|
||||
blocked = _hdhr_network_check(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
data = {
|
||||
"ScanInProgress": 0,
|
||||
"ScanPossible": 0,
|
||||
|
|
@ -155,12 +220,17 @@ class LineupStatusAPIView(APIView):
|
|||
# 🔹 5) Device XML API
|
||||
class HDHRDeviceXMLAPIView(APIView):
|
||||
"""Returns HDHomeRun device configuration in XML"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
description="Retrieve the HDHomeRun device XML configuration",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
blocked = _hdhr_network_check(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
from . import ssdp
|
||||
|
||||
|
||||
class HdhrConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.hdhr'
|
||||
verbose_name = "HDHomeRun Emulation"
|
||||
def ready(self):
|
||||
# Start SSDP services when the app is ready
|
||||
ssdp.start_ssdp()
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import gevent # Add this import
|
||||
from django.conf import settings
|
||||
|
||||
# SSDP Multicast Address and Port
|
||||
SSDP_MULTICAST = "239.255.255.250"
|
||||
SSDP_PORT = 1900
|
||||
|
||||
DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
SERVER_PORT = 8000
|
||||
|
||||
def get_host_ip():
|
||||
try:
|
||||
# This relies on "host.docker.internal" being mapped to the host’s gateway IP.
|
||||
return socket.gethostbyname("host.docker.internal")
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
def ssdp_response(addr, host_ip):
|
||||
response = (
|
||||
f"HTTP/1.1 200 OK\r\n"
|
||||
f"CACHE-CONTROL: max-age=1800\r\n"
|
||||
f"EXT:\r\n"
|
||||
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
|
||||
f"ST: {DEVICE_TYPE}\r\n"
|
||||
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
|
||||
f"\r\n"
|
||||
)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.sendto(response.encode("utf-8"), addr)
|
||||
sock.close()
|
||||
|
||||
def ssdp_listener(host_ip):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((SSDP_MULTICAST, SSDP_PORT))
|
||||
while True:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
if b"M-SEARCH" in data and DEVICE_TYPE.encode("utf-8") in data:
|
||||
print(f"Received M-SEARCH from {addr}")
|
||||
ssdp_response(addr, host_ip)
|
||||
|
||||
def ssdp_broadcaster(host_ip):
|
||||
notify = (
|
||||
f"NOTIFY * HTTP/1.1\r\n"
|
||||
f"HOST: {SSDP_MULTICAST}:{SSDP_PORT}\r\n"
|
||||
f"CACHE-CONTROL: max-age=1800\r\n"
|
||||
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
|
||||
f"NT: {DEVICE_TYPE}\r\n"
|
||||
f"NTS: ssdp:alive\r\n"
|
||||
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
|
||||
f"\r\n"
|
||||
)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
while True:
|
||||
sock.sendto(notify.encode("utf-8"), (SSDP_MULTICAST, SSDP_PORT))
|
||||
gevent.sleep(30) # Replace time.sleep with gevent.sleep
|
||||
|
||||
def start_ssdp():
|
||||
host_ip = get_host_ip()
|
||||
threading.Thread(target=ssdp_listener, args=(host_ip,), daemon=True).start()
|
||||
threading.Thread(target=ssdp_broadcaster, args=(host_ip,), daemon=True).start()
|
||||
print(f"SSDP services started on {host_ip}.")
|
||||
|
|
@ -10,12 +10,27 @@ router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device')
|
|||
urlpatterns = [
|
||||
path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'),
|
||||
path('', hdhr_dashboard_view, name='hdhr_dashboard'),
|
||||
path('<str:profile>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'),
|
||||
|
||||
# channel_profile + output_profile_id (/hdhr/<channel_profile>/output_profile/<id>/...)
|
||||
path('<str:channel_profile>/output_profile/<int:output_profile_id>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile_and_output'),
|
||||
path('<str:channel_profile>/output_profile/<int:output_profile_id>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile_and_output'),
|
||||
path('<str:channel_profile>/output_profile/<int:output_profile_id>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile_and_output'),
|
||||
|
||||
# output_profile_id only (/hdhr/output_profile/<id>/...)
|
||||
path('output_profile/<int:output_profile_id>/discover.json', DiscoverAPIView.as_view(), name='discover_with_output'),
|
||||
path('output_profile/<int:output_profile_id>/lineup.json', LineupAPIView.as_view(), name='lineup_with_output'),
|
||||
path('output_profile/<int:output_profile_id>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_output'),
|
||||
|
||||
# channel_profile only
|
||||
path('<str:channel_profile>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'),
|
||||
path('<str:channel_profile>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'),
|
||||
path('<str:channel_profile>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'),
|
||||
|
||||
# bare endpoints
|
||||
path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'),
|
||||
path('<str:profile>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'),
|
||||
path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'),
|
||||
path('<str:profile>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'),
|
||||
path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_no_profile'),
|
||||
|
||||
path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from django.views import View
|
|||
from django.utils.decorators import method_decorator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from core.utils import build_absolute_uri_with_port
|
||||
|
||||
|
||||
@login_required
|
||||
|
|
@ -46,7 +47,7 @@ class DiscoverAPIView(APIView):
|
|||
description="Retrieve HDHomeRun device discovery information",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
if not device:
|
||||
|
|
@ -84,15 +85,29 @@ class LineupAPIView(APIView):
|
|||
description="Retrieve the available channel lineup",
|
||||
)
|
||||
def get(self, request):
|
||||
channels = Channel.objects.all().order_by("channel_number")
|
||||
lineup = [
|
||||
{
|
||||
"GuideNumber": str(ch.channel_number),
|
||||
"GuideName": ch.name,
|
||||
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
|
||||
}
|
||||
for ch in channels
|
||||
]
|
||||
from apps.channels.managers import with_effective_values
|
||||
from apps.channels.utils import format_channel_number
|
||||
|
||||
channels = (
|
||||
with_effective_values(Channel.objects.all())
|
||||
.exclude(hidden_from_output=True)
|
||||
.order_by("effective_channel_number")
|
||||
)
|
||||
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
|
||||
|
||||
lineup = []
|
||||
for ch in channels:
|
||||
formatted = format_channel_number(ch.effective_channel_number, empty=None)
|
||||
if formatted is None:
|
||||
continue
|
||||
formatted_channel_number = str(formatted)
|
||||
lineup.append(
|
||||
{
|
||||
"GuideNumber": formatted_channel_number,
|
||||
"GuideName": ch.effective_name,
|
||||
"URL": f"{_stream_url_prefix}{ch.uuid}",
|
||||
}
|
||||
)
|
||||
return JsonResponse(lineup, safe=False)
|
||||
|
||||
|
||||
|
|
@ -121,7 +136,7 @@ class HDHRDeviceXMLAPIView(APIView):
|
|||
description="Retrieve the HDHomeRun device XML configuration",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ app_name = "m3u"
|
|||
router = DefaultRouter()
|
||||
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/profiles",
|
||||
r"accounts/(?P<account_id>\d+)/profiles",
|
||||
M3UAccountProfileViewSet,
|
||||
basename="m3u-account-profiles",
|
||||
)
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/filters",
|
||||
r"accounts/(?P<account_id>\d+)/filters",
|
||||
M3UFilterViewSet,
|
||||
basename="m3u-filters",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,13 @@ from rest_framework.decorators import action
|
|||
from django.conf import settings
|
||||
from .tasks import refresh_m3u_groups
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
from core.models import UserAgent
|
||||
from core.utils import safe_upload_path, ensure_custom_properties_dict
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from core.serializers import UserAgentSerializer
|
||||
from apps.vod.models import M3UVODCategoryRelation
|
||||
|
|
@ -40,7 +44,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
|
||||
queryset = M3UAccount.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).prefetch_related("channel_group", "profiles")
|
||||
).prefetch_related("channel_group", "profiles", "filters")
|
||||
serializer_class = M3UAccountSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
@ -49,15 +53,52 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Pre-aggregate stream counts for all accounts in one query so the
|
||||
# nested ChannelGroupM3UAccountSerializer never issues a COUNT per
|
||||
# group row. The serializer checks for this key and skips its own
|
||||
# per-instance query when it is present.
|
||||
from apps.channels.models import Stream
|
||||
from django.db.models import Count
|
||||
|
||||
account_ids = list(queryset.values_list("id", flat=True))
|
||||
counts_qs = (
|
||||
Stream.objects.filter(m3u_account_id__in=account_ids)
|
||||
.values("m3u_account_id", "channel_group_id")
|
||||
.annotate(c=Count("id"))
|
||||
)
|
||||
stream_counts = {
|
||||
(row["m3u_account_id"], row["channel_group_id"]): row["c"]
|
||||
for row in counts_qs
|
||||
}
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(
|
||||
page, many=True,
|
||||
context={**self.get_serializer_context(), "stream_counts": stream_counts},
|
||||
)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
queryset, many=True,
|
||||
context={**self.get_serializer_context(), "stream_counts": stream_counts},
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
# Handle file upload first, if any
|
||||
file_path = None
|
||||
if "file" in request.FILES:
|
||||
file = request.FILES["file"]
|
||||
file_name = file.name
|
||||
file_path = os.path.join("/data/uploads/m3us", file_name)
|
||||
try:
|
||||
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
|
||||
except ValueError:
|
||||
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
os.makedirs("/data/uploads/m3us", exist_ok=True)
|
||||
with open(file_path, "wb+") as destination:
|
||||
for chunk in file.chunks():
|
||||
destination.write(chunk)
|
||||
|
|
@ -117,10 +158,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
file_path = None
|
||||
if "file" in request.FILES:
|
||||
file = request.FILES["file"]
|
||||
file_name = file.name
|
||||
file_path = os.path.join("/data/uploads/m3us", file_name)
|
||||
try:
|
||||
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
|
||||
except ValueError:
|
||||
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
os.makedirs("/data/uploads/m3us", exist_ok=True)
|
||||
with open(file_path, "wb+") as destination:
|
||||
for chunk in file.chunks():
|
||||
destination.write(chunk)
|
||||
|
|
@ -220,6 +263,198 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# Continue with regular partial update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Delete an M3U account and all auto-created channels attributed
|
||||
to it. Auto-created channels with no surviving provider have no
|
||||
useful state (they cannot sync, their streams are about to
|
||||
cascade away), so the delete is unconditional: the only
|
||||
question for the user is whether to confirm. Manual channels
|
||||
are untouched, even if they include streams from this account;
|
||||
those streams cascade away independently and the channels
|
||||
survive with their other streams. The legacy
|
||||
``?cleanup_channels`` query parameter is accepted for backward
|
||||
compatibility but ignored.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
from apps.channels.models import Channel
|
||||
from apps.proxy.live_proxy.services.channel_service import (
|
||||
ChannelService,
|
||||
)
|
||||
|
||||
# Snapshot channels so proxy sessions can be stopped outside
|
||||
# the DB transaction. The pre_delete signal would otherwise
|
||||
# fire ChannelService.stop_channel (Redis pub / hgetall /
|
||||
# setex) per channel inside the atomic, holding the DB
|
||||
# connection across thousands of blocking RPCs and gumming up
|
||||
# the connection pool.
|
||||
channels_to_delete = list(
|
||||
Channel.objects.filter(
|
||||
auto_created=True,
|
||||
auto_created_by=instance,
|
||||
).values_list("id", "uuid")
|
||||
)
|
||||
for _, channel_uuid in channels_to_delete:
|
||||
if not channel_uuid:
|
||||
continue
|
||||
try:
|
||||
ChannelService.stop_channel(str(channel_uuid))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to stop proxy session for channel %s "
|
||||
"during account cleanup: %s",
|
||||
channel_uuid,
|
||||
e,
|
||||
)
|
||||
|
||||
channel_ids = [cid for cid, _ in channels_to_delete]
|
||||
# Channel + account writes share an atomic so an account
|
||||
# delete failure rolls back the channel deletes too. The
|
||||
# pre_delete signal will fire again here but its proxy stop
|
||||
# is fast on already-stopped channels (a single Redis check
|
||||
# returns "not found" immediately).
|
||||
with transaction.atomic():
|
||||
if channel_ids:
|
||||
_, per_model = Channel.objects.filter(
|
||||
id__in=channel_ids
|
||||
).delete()
|
||||
deleted_channels = per_model.get(
|
||||
"dispatcharr_channels.Channel", 0
|
||||
)
|
||||
else:
|
||||
deleted_channels = 0
|
||||
response = super().destroy(request, *args, **kwargs)
|
||||
|
||||
# Surface the channel count alongside the standard 204; the
|
||||
# confirmation toast renders the number to acknowledge what
|
||||
# the cascade actually removed.
|
||||
if response.status_code == status.HTTP_204_NO_CONTENT:
|
||||
return Response(
|
||||
{"deleted_channels": deleted_channels},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return response
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {"type": "integer"},
|
||||
"sample_names": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["get"], url_path="auto-created-channels-count")
|
||||
def auto_created_channels_count(self, request, pk=None):
|
||||
"""
|
||||
Preview how many auto-created channels would be removed if the account
|
||||
were deleted with cleanup_channels=true. The frontend calls this when
|
||||
the user clicks Delete, to render a truthful confirmation dialog
|
||||
("Also delete N channels auto-created by this provider?").
|
||||
"""
|
||||
account = self.get_object()
|
||||
from apps.channels.models import Channel
|
||||
|
||||
qs = Channel.objects.filter(
|
||||
auto_created=True, auto_created_by=account
|
||||
)
|
||||
count = qs.count()
|
||||
sample_names = list(qs.values_list("name", flat=True)[:5])
|
||||
return Response({"count": count, "sample_names": sample_names})
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="channel_group_id",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description=(
|
||||
"ID of the ChannelGroup whose auto-created channels "
|
||||
"should be repacked."
|
||||
),
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assigned": {"type": "integer"},
|
||||
"released": {"type": "integer"},
|
||||
"failed": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="repack-group")
|
||||
def repack_group(self, request, pk=None):
|
||||
"""
|
||||
Manually re-pack visible channels in one of this account's
|
||||
groups into the group's [start, end] range. Override-pinned
|
||||
numbers are treated as reservations and skipped. Hidden channels
|
||||
without overrides have their channel_number set to NULL.
|
||||
|
||||
Useful when the user has just finished customizing channels
|
||||
(setting overrides as pins, hiding unwanted streams) and wants
|
||||
the result reflected immediately rather than on the next M3U
|
||||
refresh. Also acts as a one-shot cleanup for groups that aren't
|
||||
running in compact mode but have accumulated gaps.
|
||||
"""
|
||||
account = self.get_object()
|
||||
group_id_raw = request.query_params.get("channel_group_id")
|
||||
if not group_id_raw:
|
||||
return Response(
|
||||
{"detail": "channel_group_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
group_id = int(group_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"detail": "channel_group_id must be an integer"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from apps.channels.compact_numbering import repack_group as _repack
|
||||
from core.utils import acquire_task_lock, release_task_lock
|
||||
|
||||
# Share the lock that wraps the entire refresh-plus-sync pipeline
|
||||
# (`refresh_single_m3u_account`). The narrower
|
||||
# `refresh_m3u_account_groups` lock is released before
|
||||
# `sync_auto_channels` runs, so it would not protect this writer
|
||||
# from racing against the channel_number writes inside sync.
|
||||
if not acquire_task_lock("refresh_single_m3u_account", account.id):
|
||||
return Response(
|
||||
{"detail": "An M3U refresh is in progress for this account."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
try:
|
||||
# Re-fetch under the lock so a sync that just released its lock
|
||||
# cannot leave the cached group_relation reflecting pre-sync
|
||||
# custom_properties (auto_sync_channel_start/end, etc.).
|
||||
try:
|
||||
group_relation = ChannelGroupM3UAccount.objects.get(
|
||||
m3u_account=account, channel_group_id=group_id
|
||||
)
|
||||
except ChannelGroupM3UAccount.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Group is not associated with this account"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
result = _repack(group_relation)
|
||||
finally:
|
||||
try:
|
||||
release_task_lock("refresh_single_m3u_account", account.id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to release repack lock for account "
|
||||
f"{account.id}: {e}"
|
||||
)
|
||||
return Response(result)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="refresh-vod")
|
||||
def refresh_vod(self, request, pk=None):
|
||||
"""Trigger VOD content refresh for XtreamCodes accounts"""
|
||||
|
|
@ -265,6 +500,33 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
category_settings = request.data.get("category_settings", [])
|
||||
|
||||
try:
|
||||
for setting in group_settings:
|
||||
start = setting.get("auto_sync_channel_start")
|
||||
end = setting.get("auto_sync_channel_end")
|
||||
if (start is not None and start < 1) or (
|
||||
end is not None and end < 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
f"Channel group {setting.get('channel_group')}: "
|
||||
f"channel range must be >= 1."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if start is not None and end is not None and end < start:
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
f"Channel group {setting.get('channel_group')}: "
|
||||
f"auto_sync_channel_end must be >= "
|
||||
f"auto_sync_channel_start."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
group_objects = [
|
||||
ChannelGroupM3UAccount(
|
||||
|
|
@ -273,7 +535,10 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
enabled=setting.get("enabled", True),
|
||||
auto_channel_sync=setting.get("auto_channel_sync", False),
|
||||
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
auto_sync_channel_end=setting.get("auto_sync_channel_end"),
|
||||
custom_properties=ensure_custom_properties_dict(
|
||||
setting.get("custom_properties")
|
||||
),
|
||||
)
|
||||
for setting in group_settings
|
||||
if setting.get("channel_group")
|
||||
|
|
@ -288,6 +553,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
"enabled",
|
||||
"auto_channel_sync",
|
||||
"auto_sync_channel_start",
|
||||
"auto_sync_channel_end",
|
||||
"custom_properties",
|
||||
],
|
||||
)
|
||||
|
|
@ -297,7 +563,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
category_id=setting["id"],
|
||||
m3u_account=account,
|
||||
enabled=setting.get("enabled", True),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
custom_properties=ensure_custom_properties_dict(
|
||||
setting.get("custom_properties")
|
||||
),
|
||||
)
|
||||
for setting in category_settings
|
||||
if setting.get("id")
|
||||
|
|
|
|||
323
apps/m3u/connection_pool.py
Normal file
323
apps/m3u/connection_pool.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""
|
||||
Shared connection pool enforcement for M3U accounts in the same ServerGroup.
|
||||
|
||||
Profile selection rotates across M3UAccountProfile rows using each profile's own
|
||||
Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup, a credential-scoped counter is checked on reserve/release
|
||||
so accounts sharing the same provider login share one limit without blocking
|
||||
unrelated logins on the same group. Account profiles with max_streams=0 skip
|
||||
credential enforcement for that profile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ReserveFailureReason = Literal["profile_full", "credential_full"]
|
||||
|
||||
PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}"
|
||||
PROFILE_CREDENTIAL_RELEASE_KEY = "profile_credential_release:{profile_id}"
|
||||
SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}:{fingerprint}"
|
||||
|
||||
_XC_URL_CREDENTIALS_RE = re.compile(
|
||||
r"/(?:live|movie|series)/([^/]+)/([^/]+)/",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def profile_connections_key(profile_id: int) -> str:
|
||||
return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id)
|
||||
|
||||
|
||||
def profile_credential_release_key(profile_id: int) -> str:
|
||||
"""Redis key storing the credential counter to release when the profile row is gone."""
|
||||
return PROFILE_CREDENTIAL_RELEASE_KEY.format(profile_id=profile_id)
|
||||
|
||||
|
||||
def server_group_connections_key(group_id: int, fingerprint: str) -> str:
|
||||
"""Redis key for per-credential usage within a ServerGroup."""
|
||||
return SERVER_GROUP_CONNECTIONS_KEY.format(
|
||||
group_id=group_id,
|
||||
fingerprint=fingerprint[:16],
|
||||
)
|
||||
|
||||
|
||||
def compute_credential_fingerprint(username: str, password: str) -> Optional[str]:
|
||||
"""Return a stable hash for grouping accounts with the same IPTV login."""
|
||||
if not username or not password:
|
||||
return None
|
||||
normalized = f"{username.strip().lower()}\0{password.strip()}"
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def extract_credentials_from_stream_url(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Parse username/password embedded in an Xtream-style stream URL."""
|
||||
if not url:
|
||||
return None, None
|
||||
match = _XC_URL_CREDENTIALS_RE.search(url)
|
||||
if not match:
|
||||
return None, None
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
def _fingerprint_from_profile_stream_url(profile) -> Optional[str]:
|
||||
"""STD/M3U: fingerprint from a sample stream URL after profile rewrite."""
|
||||
from apps.channels.models import Stream
|
||||
|
||||
sample_url = (
|
||||
Stream.objects.filter(m3u_account=profile.m3u_account)
|
||||
.exclude(url="")
|
||||
.values_list("url", flat=True)
|
||||
.first()
|
||||
)
|
||||
if not sample_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
from apps.proxy.live_proxy.url_utils import transform_url
|
||||
|
||||
transformed = transform_url(
|
||||
sample_url,
|
||||
profile.search_pattern or "",
|
||||
profile.replace_pattern or "",
|
||||
)
|
||||
url_user, url_pass = extract_credentials_from_stream_url(
|
||||
transformed or sample_url
|
||||
)
|
||||
return compute_credential_fingerprint(url_user or "", url_pass or "")
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not derive profile %s fingerprint from stream URL: %s",
|
||||
profile.pk,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_profile_credential_fingerprint(profile) -> Optional[str]:
|
||||
"""Fingerprint for credentials this profile uses at playback time."""
|
||||
m3u_account = profile.m3u_account
|
||||
|
||||
if m3u_account.account_type == "XC":
|
||||
try:
|
||||
from apps.m3u.tasks import get_transformed_credentials
|
||||
|
||||
_url, username, password = get_transformed_credentials(m3u_account, profile)
|
||||
fingerprint = compute_credential_fingerprint(username or "", password or "")
|
||||
if fingerprint:
|
||||
return fingerprint
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not resolve transformed credentials for profile %s: %s",
|
||||
profile.pk,
|
||||
exc,
|
||||
)
|
||||
|
||||
fingerprint = _fingerprint_from_profile_stream_url(profile)
|
||||
if fingerprint:
|
||||
return fingerprint
|
||||
|
||||
return compute_credential_fingerprint(
|
||||
m3u_account.username or "",
|
||||
m3u_account.password or "",
|
||||
)
|
||||
|
||||
|
||||
def get_enforced_server_group_for_profile(profile):
|
||||
"""Return the ServerGroup for credential pooling when the account is assigned to one."""
|
||||
group = profile.m3u_account.server_group
|
||||
if group:
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def _credential_counter_key(profile, group) -> Optional[str]:
|
||||
fingerprint = get_profile_credential_fingerprint(profile)
|
||||
if not fingerprint:
|
||||
return None
|
||||
return server_group_connections_key(group.id, fingerprint)
|
||||
|
||||
|
||||
def get_profile_connection_count(profile, redis_client) -> int:
|
||||
return int(redis_client.get(profile_connections_key(profile.id)) or 0)
|
||||
|
||||
|
||||
def get_credential_connection_count(profile, redis_client) -> int:
|
||||
group = get_enforced_server_group_for_profile(profile)
|
||||
if not group:
|
||||
return 0
|
||||
cred_key = _credential_counter_key(profile, group)
|
||||
if not cred_key:
|
||||
return 0
|
||||
return int(redis_client.get(cred_key) or 0)
|
||||
|
||||
|
||||
def profile_has_capacity_for_selection(profile, redis_client) -> bool:
|
||||
"""Per-profile capacity check used when rotating across profiles on one account."""
|
||||
if profile.max_streams == 0:
|
||||
return True
|
||||
return get_profile_connection_count(profile, redis_client) < profile.max_streams
|
||||
|
||||
|
||||
def group_has_capacity_for_profile(profile, redis_client) -> bool:
|
||||
# Profiles with max_streams=0 skip credential enforcement entirely. An unlimited
|
||||
# profile in a pooled group can still stream while other accounts share the login.
|
||||
group = get_enforced_server_group_for_profile(profile)
|
||||
if not group or profile.max_streams == 0:
|
||||
return True
|
||||
cred_key = _credential_counter_key(profile, group)
|
||||
if not cred_key:
|
||||
return True
|
||||
return int(redis_client.get(cred_key) or 0) < profile.max_streams
|
||||
|
||||
|
||||
def pool_has_capacity_for_profile(profile, redis_client) -> bool:
|
||||
"""Non-mutating check before reserve: profile slot and credential slot if applicable."""
|
||||
return profile_has_capacity_for_selection(profile, redis_client) and group_has_capacity_for_profile(
|
||||
profile, redis_client
|
||||
)
|
||||
|
||||
|
||||
def profile_available_for_channel_switch(
|
||||
profile, redis_client, *, channel_already_on_profile: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Non-mutating capacity check when selecting a profile for an in-flight channel.
|
||||
|
||||
If the channel already holds this profile's slots, skip re-checking capacity.
|
||||
"""
|
||||
if channel_already_on_profile:
|
||||
return True
|
||||
return pool_has_capacity_for_profile(profile, redis_client)
|
||||
|
||||
|
||||
def move_credential_slot_on_profile_switch(
|
||||
old_profile, new_profile, redis_client
|
||||
) -> bool:
|
||||
"""
|
||||
Move the shared credential counter when switching to a different provider login.
|
||||
|
||||
Profile counters are managed separately by Channel.update_stream_profile().
|
||||
Returns False when the new profile's credential pool is full.
|
||||
"""
|
||||
old_fp = get_profile_credential_fingerprint(old_profile)
|
||||
new_fp = get_profile_credential_fingerprint(new_profile)
|
||||
if old_fp == new_fp:
|
||||
return True
|
||||
|
||||
_release_credential_slot_by_profile_id(old_profile.id, redis_client)
|
||||
|
||||
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
|
||||
new_profile, redis_client
|
||||
)
|
||||
if not cred_reserved:
|
||||
restore_reserved, restore_key = _reserve_server_group_slot_for_profile(
|
||||
old_profile, redis_client
|
||||
)
|
||||
if restore_reserved and restore_key:
|
||||
_remember_credential_release_key(
|
||||
old_profile.id, restore_key, redis_client
|
||||
)
|
||||
return False
|
||||
|
||||
if cred_key:
|
||||
_remember_credential_release_key(new_profile.id, cred_key, redis_client)
|
||||
return True
|
||||
|
||||
|
||||
def _safe_decr(redis_client, key: str) -> None:
|
||||
current = int(redis_client.get(key) or 0)
|
||||
if current <= 0:
|
||||
return
|
||||
new_count = redis_client.decr(key)
|
||||
if new_count < 0:
|
||||
redis_client.set(key, 0)
|
||||
|
||||
|
||||
def _remember_credential_release_key(
|
||||
profile_id: int, cred_key: str, redis_client
|
||||
) -> None:
|
||||
redis_client.set(profile_credential_release_key(profile_id), cred_key)
|
||||
|
||||
|
||||
def _release_credential_slot_by_profile_id(profile_id: int, redis_client) -> bool:
|
||||
"""Release a reserved credential counter using the key stored at reserve time."""
|
||||
release_key = profile_credential_release_key(profile_id)
|
||||
cred_key = redis_client.get(release_key)
|
||||
if not cred_key:
|
||||
return False
|
||||
|
||||
if isinstance(cred_key, bytes):
|
||||
cred_key = cred_key.decode()
|
||||
_safe_decr(redis_client, cred_key)
|
||||
redis_client.delete(release_key)
|
||||
return True
|
||||
|
||||
|
||||
def _reserve_server_group_slot_for_profile(
|
||||
profile, redis_client
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
group = get_enforced_server_group_for_profile(profile)
|
||||
if not group or profile.max_streams == 0:
|
||||
return True, None
|
||||
|
||||
cred_key = _credential_counter_key(profile, group)
|
||||
if not cred_key:
|
||||
return True, None
|
||||
|
||||
cred_count = redis_client.incr(cred_key)
|
||||
if cred_count <= profile.max_streams:
|
||||
return True, cred_key
|
||||
|
||||
redis_client.decr(cred_key)
|
||||
return False, None
|
||||
|
||||
|
||||
def reserve_profile_slot(
|
||||
profile, redis_client
|
||||
) -> Tuple[bool, int, Optional[ReserveFailureReason]]:
|
||||
"""
|
||||
Atomically reserve profile + optional credential slots (INCR-first).
|
||||
|
||||
Returns (reserved, profile_count_after_attempt, failure_reason).
|
||||
failure_reason is set when reserved is False.
|
||||
"""
|
||||
profile_key = profile_connections_key(profile.id)
|
||||
profile_count = 0
|
||||
|
||||
if profile.max_streams > 0:
|
||||
profile_count = redis_client.incr(profile_key)
|
||||
if profile_count > profile.max_streams:
|
||||
redis_client.decr(profile_key)
|
||||
return False, profile_count - 1, "profile_full"
|
||||
|
||||
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
|
||||
profile, redis_client
|
||||
)
|
||||
if not cred_reserved:
|
||||
if profile.max_streams > 0:
|
||||
redis_client.decr(profile_key)
|
||||
return (
|
||||
False,
|
||||
profile_count - 1 if profile.max_streams > 0 else 0,
|
||||
"credential_full",
|
||||
)
|
||||
|
||||
if cred_key:
|
||||
_remember_credential_release_key(profile.id, cred_key, redis_client)
|
||||
|
||||
return True, profile_count, None
|
||||
|
||||
|
||||
def release_profile_slot(profile_id: int, redis_client) -> None:
|
||||
"""Release profile and shared credential slots after a stream end."""
|
||||
_release_credential_slot_by_profile_id(profile_id, redis_client)
|
||||
|
||||
profile_key = profile_connections_key(profile_id)
|
||||
current = int(redis_client.get(profile_key) or 0)
|
||||
if current > 0:
|
||||
redis_client.decr(profile_key)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# apps/m3u/forms.py
|
||||
from django import forms
|
||||
from .models import M3UAccount, M3UFilter
|
||||
from core.utils import ensure_custom_properties_dict
|
||||
import re
|
||||
|
||||
class M3UAccountForm(forms.ModelForm):
|
||||
|
|
@ -28,7 +29,9 @@ class M3UAccountForm(forms.ModelForm):
|
|||
|
||||
# Set initial value for enable_vod from custom_properties
|
||||
if self.instance and self.instance.custom_properties:
|
||||
custom_props = self.instance.custom_properties or {}
|
||||
custom_props = self.instance.custom_properties
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
|
||||
|
||||
def save(self, commit=True):
|
||||
|
|
@ -37,8 +40,9 @@ class M3UAccountForm(forms.ModelForm):
|
|||
# Handle enable_vod field
|
||||
enable_vod = self.cleaned_data.get('enable_vod', False)
|
||||
|
||||
# Parse existing custom_properties
|
||||
custom_props = instance.custom_properties or {}
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
|
||||
# Update VOD preference
|
||||
custom_props['enable_vod'] = enable_vod
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.dispatch import receiver
|
|||
from apps.channels.models import StreamProfile
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from core.models import CoreSettings, UserAgent
|
||||
from core.utils import custom_properties_as_dict
|
||||
|
||||
CUSTOM_M3U_ACCOUNT_NAME = "custom"
|
||||
|
||||
|
|
@ -100,7 +101,6 @@ class M3UAccount(models.Model):
|
|||
default=0,
|
||||
help_text="Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
|
@ -111,18 +111,6 @@ class M3UAccount(models.Model):
|
|||
def display_action(self):
|
||||
return "Exclude" if self.exclude else "Include"
|
||||
|
||||
def deactivate_streams(self):
|
||||
"""Deactivate all streams linked to this account."""
|
||||
for stream in self.streams.all():
|
||||
stream.is_active = False
|
||||
stream.save()
|
||||
|
||||
def reactivate_streams(self):
|
||||
"""Reactivate all streams linked to this account."""
|
||||
for stream in self.streams.all():
|
||||
stream.is_active = True
|
||||
stream.save()
|
||||
|
||||
@classmethod
|
||||
def get_custom_account(cls):
|
||||
return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True)
|
||||
|
|
@ -137,6 +125,11 @@ class M3UAccount(models.Model):
|
|||
return user_agent
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
|
||||
# Don't modify updated_at for regular updates
|
||||
|
|
@ -205,28 +198,15 @@ class M3UFilter(models.Model):
|
|||
exclude_status = "Exclude" if self.exclude else "Include"
|
||||
return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})"
|
||||
|
||||
@staticmethod
|
||||
def filter_streams(streams, filters):
|
||||
included_streams = set()
|
||||
excluded_streams = set()
|
||||
|
||||
for f in filters:
|
||||
for stream in streams:
|
||||
if f.applies_to(stream.name, stream.group_name):
|
||||
if f.exclude:
|
||||
excluded_streams.add(stream)
|
||||
else:
|
||||
included_streams.add(stream)
|
||||
|
||||
# If no include filters exist, assume all non-excluded streams are valid
|
||||
if not any(not f.exclude for f in filters):
|
||||
return streams.exclude(id__in=[s.id for s in excluded_streams])
|
||||
|
||||
return streams.filter(id__in=[s.id for s in included_streams])
|
||||
|
||||
|
||||
class ServerGroup(models.Model):
|
||||
"""Represents a logical grouping of servers or channels."""
|
||||
"""
|
||||
Groups M3U accounts that share provider credentials.
|
||||
|
||||
Accounts assigned to the same server group share credential-scoped connection
|
||||
counters when their logins match. Limits come from each account profile's
|
||||
max_streams, not from the group itself.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100, unique=True, help_text="Unique name for this server group."
|
||||
|
|
@ -289,6 +269,11 @@ class M3UAccountProfile(models.Model):
|
|||
def save(self, *args, **kwargs):
|
||||
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
|
||||
For non-XC accounts, exp_date is set directly and left untouched here."""
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
|
||||
parsed = self._parse_exp_date_from_custom_properties()
|
||||
if parsed is not None:
|
||||
# XC account with exp_date in custom_properties — always sync
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, ensure_custom_properties_dict
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.response import Response
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
|
|
@ -93,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
|
|||
|
||||
def update(self, instance, validated_data):
|
||||
if instance.is_default:
|
||||
# For default profiles, only allow updating name, custom_properties, and exp_date
|
||||
allowed_fields = {'name', 'custom_properties', 'exp_date'}
|
||||
# For default profiles, only allow updating name, custom_properties, exp_date, and patterns
|
||||
allowed_fields = {'name', 'custom_properties', 'exp_date', 'search_pattern', 'replace_pattern'}
|
||||
|
||||
# Remove any fields that aren't allowed for default profiles
|
||||
disallowed_fields = set(validated_data.keys()) - allowed_fields
|
||||
if disallowed_fields:
|
||||
raise serializers.ValidationError(
|
||||
f"Default profiles can only modify name, notes, and expiration. "
|
||||
f"Default profiles can only modify name, notes, expiration, and URL patterns. "
|
||||
f"Cannot modify: {', '.join(disallowed_fields)}"
|
||||
)
|
||||
|
||||
|
|
@ -193,6 +193,24 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
# When the list() view pre-aggregates stream counts for all accounts
|
||||
# in a single query, it seeds "stream_counts" into the context before
|
||||
# serialization. Avoid issuing a redundant per-instance COUNT in that
|
||||
# case. The per-instance fallback handles direct serialization (e.g.
|
||||
# retrieve, create) where only one account is in scope.
|
||||
if "stream_counts" not in self.context:
|
||||
from django.db.models import Count
|
||||
from apps.channels.models import Stream
|
||||
|
||||
counts_qs = (
|
||||
Stream.objects.filter(m3u_account_id=instance.id)
|
||||
.values("channel_group_id")
|
||||
.annotate(c=Count("id"))
|
||||
)
|
||||
self.context["stream_counts"] = {
|
||||
(instance.id, row["channel_group_id"]): row["c"] for row in counts_qs
|
||||
}
|
||||
|
||||
data = super().to_representation(instance)
|
||||
|
||||
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
|
||||
|
|
@ -247,10 +265,21 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", None)
|
||||
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", None)
|
||||
|
||||
# Get existing custom_properties
|
||||
custom_props = instance.custom_properties or {}
|
||||
# Merge client-supplied custom_properties over the existing blob
|
||||
# so unrelated keys persist. The dedicated preference fields below
|
||||
# overwrite their corresponding keys; clients should set those via
|
||||
# the typed top-level fields rather than the custom_properties
|
||||
# payload.
|
||||
incoming_custom = {}
|
||||
if "custom_properties" in validated_data:
|
||||
incoming_custom = validated_data["custom_properties"] or {}
|
||||
if not isinstance(incoming_custom, dict):
|
||||
incoming_custom = ensure_custom_properties_dict(incoming_custom)
|
||||
existing_custom = instance.custom_properties or {}
|
||||
if not isinstance(existing_custom, dict):
|
||||
existing_custom = ensure_custom_properties_dict(existing_custom)
|
||||
custom_props = {**existing_custom, **incoming_custom}
|
||||
|
||||
# Update preferences
|
||||
if enable_vod is not None:
|
||||
custom_props["enable_vod"] = enable_vod
|
||||
if auto_enable_new_groups_live is not None:
|
||||
|
|
@ -321,7 +350,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
|
||||
|
||||
# Parse existing custom_properties or create new
|
||||
custom_props = validated_data.get("custom_properties", {})
|
||||
custom_props = validated_data.get("custom_properties") or {}
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
|
||||
# Set preferences (default to True for auto_enable_new_groups)
|
||||
custom_props["enable_vod"] = enable_vod
|
||||
|
|
@ -345,7 +376,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def get_filters(self, obj):
|
||||
filters = obj.filters.order_by("order")
|
||||
# Sort over the prefetch cache; .order_by() would fire one SELECT
|
||||
# per account (viewset prefetches "filters").
|
||||
filters = sorted(obj.filters.all(), key=lambda f: f.order)
|
||||
return M3UFilterSerializer(filters, many=True).data
|
||||
|
||||
def get_earliest_expiration(self, obj):
|
||||
|
|
|
|||
1625
apps/m3u/tasks.py
1625
apps/m3u/tasks.py
File diff suppressed because it is too large
Load diff
143
apps/m3u/tests/test_account_destroy.py
Normal file
143
apps/m3u/tests/test_account_destroy.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""
|
||||
DELETE /api/m3u/accounts/{id}/ behavior.
|
||||
|
||||
The endpoint always cascade-deletes auto-created channels owned by the
|
||||
account. Manual channels survive even if some of their streams were
|
||||
owned by the deleted account; only the streams from that account go
|
||||
away with the account, and the channel keeps any streams sourced from
|
||||
other accounts. The legacy ``cleanup_channels`` query parameter is
|
||||
accepted but ignored.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.channels.models import (
|
||||
Channel,
|
||||
ChannelGroup,
|
||||
ChannelStream,
|
||||
Stream,
|
||||
)
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class M3UAccountDestroyTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="destroyer", password="testpass123"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
# Patching the proxy stop here keeps the Redis-backed call out
|
||||
# of the test path. The endpoint loops over every auto-created
|
||||
# channel before the DB transaction; the real implementation is
|
||||
# exercised by integration tests, not unit tests.
|
||||
self._stop_patch = patch(
|
||||
"apps.proxy.live_proxy.services.channel_service."
|
||||
"ChannelService.stop_channel"
|
||||
)
|
||||
self._stop_patch.start()
|
||||
self.addCleanup(self._stop_patch.stop)
|
||||
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="ProviderA",
|
||||
server_url="http://example.com/a.m3u",
|
||||
)
|
||||
self.other_account = M3UAccount.objects.create(
|
||||
name="ProviderB",
|
||||
server_url="http://example.com/b.m3u",
|
||||
)
|
||||
self.group = ChannelGroup.objects.create(name="News")
|
||||
|
||||
def _make_stream(self, account, name="ESPN"):
|
||||
return Stream.objects.create(
|
||||
name=name,
|
||||
url=f"http://example.com/{name.lower()}.m3u8",
|
||||
m3u_account=account,
|
||||
channel_group=self.group,
|
||||
tvg_id=name.lower(),
|
||||
last_seen=timezone.now(),
|
||||
)
|
||||
|
||||
def test_cascade_deletes_auto_created_channels(self):
|
||||
# Two auto-created channels under the account being deleted.
|
||||
for n in (101.0, 102.0):
|
||||
ch = Channel.objects.create(
|
||||
channel_number=n,
|
||||
name=f"Auto {n}",
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=ch,
|
||||
stream=self._make_stream(self.account, name=f"Auto{int(n)}"),
|
||||
order=0,
|
||||
)
|
||||
|
||||
response = self.client.delete(
|
||||
f"/api/m3u/accounts/{self.account.id}/"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["deleted_channels"], 2)
|
||||
self.assertFalse(M3UAccount.objects.filter(id=self.account.id).exists())
|
||||
self.assertFalse(
|
||||
Channel.objects.filter(auto_created_by_id=self.account.id).exists()
|
||||
)
|
||||
|
||||
def test_manual_channel_survives_with_other_provider_streams(self):
|
||||
# Manual channel with one stream from each account. The provider
|
||||
# account's stream goes away with the account; the other
|
||||
# account's stream stays, and the channel survives.
|
||||
manual = Channel.objects.create(
|
||||
channel_number=200.0,
|
||||
name="Manual",
|
||||
channel_group=self.group,
|
||||
auto_created=False,
|
||||
)
|
||||
provider_stream = self._make_stream(self.account, name="ProviderA")
|
||||
other_stream = self._make_stream(self.other_account, name="ProviderB")
|
||||
ChannelStream.objects.create(channel=manual, stream=provider_stream, order=0)
|
||||
ChannelStream.objects.create(channel=manual, stream=other_stream, order=1)
|
||||
|
||||
response = self.client.delete(
|
||||
f"/api/m3u/accounts/{self.account.id}/"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["deleted_channels"], 0)
|
||||
|
||||
manual.refresh_from_db()
|
||||
remaining = list(manual.channelstream_set.values_list("stream__id", flat=True))
|
||||
self.assertEqual(remaining, [other_stream.id])
|
||||
|
||||
def test_legacy_cleanup_channels_param_is_ignored(self):
|
||||
# Behavior must be identical with or without the deprecated
|
||||
# ``cleanup_channels`` query parameter.
|
||||
ch = Channel.objects.create(
|
||||
channel_number=300.0,
|
||||
name="Auto",
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
)
|
||||
response = self.client.delete(
|
||||
f"/api/m3u/accounts/{self.account.id}/?cleanup_channels=false"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["deleted_channels"], 1)
|
||||
self.assertFalse(Channel.objects.filter(id=ch.id).exists())
|
||||
|
||||
def test_no_op_when_account_has_no_auto_created_channels(self):
|
||||
response = self.client.delete(
|
||||
f"/api/m3u/accounts/{self.account.id}/"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["deleted_channels"], 0)
|
||||
594
apps/m3u/tests/test_connection_pool.py
Normal file
594
apps/m3u/tests/test_connection_pool.py
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
"""Tests for shared ServerGroup connection pools (#1137)."""
|
||||
|
||||
from django.test import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.m3u.connection_pool import (
|
||||
extract_credentials_from_stream_url,
|
||||
get_credential_connection_count,
|
||||
get_enforced_server_group_for_profile,
|
||||
get_profile_connection_count,
|
||||
get_profile_credential_fingerprint,
|
||||
group_has_capacity_for_profile,
|
||||
pool_has_capacity_for_profile,
|
||||
profile_has_capacity_for_selection,
|
||||
profile_connections_key,
|
||||
profile_credential_release_key,
|
||||
release_profile_slot,
|
||||
reserve_profile_slot,
|
||||
server_group_connections_key,
|
||||
)
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal in-memory Redis stand-in for counter tests."""
|
||||
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key):
|
||||
val = self._data.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return val.encode()
|
||||
return str(val).encode()
|
||||
|
||||
def set(self, key, value, ex=None):
|
||||
try:
|
||||
self._data[key] = int(value)
|
||||
except (ValueError, TypeError):
|
||||
self._data[key] = value
|
||||
|
||||
def incr(self, key):
|
||||
self._data[key] = self._data.get(key, 0) + 1
|
||||
return self._data[key]
|
||||
|
||||
def decr(self, key):
|
||||
self._data[key] = self._data.get(key, 0) - 1
|
||||
return self._data[key]
|
||||
|
||||
def delete(self, key):
|
||||
self._data.pop(key, None)
|
||||
|
||||
def pipeline(self):
|
||||
return FakeRedisPipeline(self)
|
||||
|
||||
|
||||
class FakeRedisPipeline:
|
||||
def __init__(self, redis):
|
||||
self.redis = redis
|
||||
self._ops = []
|
||||
|
||||
def decr(self, key):
|
||||
self._ops.append(("decr", key))
|
||||
return self
|
||||
|
||||
def incr(self, key):
|
||||
self._ops.append(("incr", key))
|
||||
return self
|
||||
|
||||
def set(self, key, value):
|
||||
self._ops.append(("set", key, value))
|
||||
return self
|
||||
|
||||
def execute(self):
|
||||
for op in self._ops:
|
||||
if op[0] == "decr":
|
||||
self.redis.decr(op[1])
|
||||
elif op[0] == "incr":
|
||||
self.redis.incr(op[1])
|
||||
elif op[0] == "set":
|
||||
self.redis.set(op[1], op[2])
|
||||
self._ops = []
|
||||
|
||||
|
||||
class ExtractCredentialsTests(TestCase):
|
||||
def test_extract_credentials_from_xc_style_url(self):
|
||||
url = "http://example.com/live/alice/secret123/99999.ts"
|
||||
user, password = extract_credentials_from_stream_url(url)
|
||||
self.assertEqual(user, "alice")
|
||||
self.assertEqual(password, "secret123")
|
||||
|
||||
|
||||
class ManualServerGroupTests(TestCase):
|
||||
def test_group_enforced_when_account_assigned(self):
|
||||
group = ServerGroup.objects.create(name="provider-a")
|
||||
account = M3UAccount.objects.create(
|
||||
name="Account A",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_group=group,
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
|
||||
self.assertEqual(get_enforced_server_group_for_profile(profile), group)
|
||||
|
||||
def test_accounts_in_same_group_share_credential_counter(self):
|
||||
group = ServerGroup.objects.create(name="shared")
|
||||
account1 = M3UAccount.objects.create(
|
||||
name="XC Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
account2 = M3UAccount.objects.create(
|
||||
name="M3U Account",
|
||||
account_type="STD",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile1 = M3UAccountProfile.objects.get(m3u_account=account1, is_default=True)
|
||||
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||
profile1.max_streams = 1
|
||||
profile1.save()
|
||||
profile2.max_streams = 1
|
||||
profile2.save()
|
||||
|
||||
redis = FakeRedis()
|
||||
reserved1, _, _ = reserve_profile_slot(profile1, redis)
|
||||
self.assertTrue(reserved1)
|
||||
|
||||
reserved2, _, _ = reserve_profile_slot(profile2, redis)
|
||||
self.assertFalse(reserved2)
|
||||
self.assertFalse(group_has_capacity_for_profile(profile2, redis))
|
||||
|
||||
def test_profile_rotation_when_default_profile_full(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="Multi-profile",
|
||||
account_type="XC",
|
||||
max_streams=1,
|
||||
)
|
||||
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
default.max_streams = 1
|
||||
default.save()
|
||||
|
||||
alt = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt_profile",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
redis = FakeRedis()
|
||||
reserved, _, _ = reserve_profile_slot(default, redis)
|
||||
self.assertTrue(reserved)
|
||||
self.assertFalse(profile_has_capacity_for_selection(default, redis))
|
||||
self.assertTrue(profile_has_capacity_for_selection(alt, redis))
|
||||
|
||||
|
||||
class PoolEnforcementTests(TestCase):
|
||||
def setUp(self):
|
||||
self.redis = FakeRedis()
|
||||
self.group = ServerGroup.objects.create(name="test-pool")
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="Test Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=self.group,
|
||||
max_streams=5,
|
||||
)
|
||||
self.profile = M3UAccountProfile.objects.get(
|
||||
m3u_account=self.account, is_default=True
|
||||
)
|
||||
self.profile.max_streams = 1
|
||||
self.profile.save()
|
||||
|
||||
def test_group_has_capacity_reads_credential_counter_directly(self):
|
||||
cred_key = server_group_connections_key(
|
||||
self.group.id,
|
||||
get_profile_credential_fingerprint(self.profile),
|
||||
)
|
||||
self.redis.set(cred_key, 1)
|
||||
|
||||
self.assertFalse(group_has_capacity_for_profile(self.profile, self.redis))
|
||||
|
||||
self.redis.set(cred_key, 0)
|
||||
self.assertTrue(group_has_capacity_for_profile(self.profile, self.redis))
|
||||
|
||||
def test_reserve_and_release_both_counters(self):
|
||||
reserved, count, _ = reserve_profile_slot(self.profile, self.redis)
|
||||
self.assertTrue(reserved)
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
cred_key = server_group_connections_key(
|
||||
self.group.id,
|
||||
get_profile_credential_fingerprint(self.profile),
|
||||
)
|
||||
profile_key = profile_connections_key(self.profile.id)
|
||||
self.assertEqual(self.redis._data[cred_key], 1)
|
||||
self.assertEqual(self.redis._data[profile_key], 1)
|
||||
|
||||
release_profile_slot(self.profile.id, self.redis)
|
||||
self.assertEqual(self.redis._data[cred_key], 0)
|
||||
self.assertEqual(self.redis._data[profile_key], 0)
|
||||
|
||||
def test_same_credential_capped_at_profile_max(self):
|
||||
"""Shared credential counter is capped by each profile's max_streams."""
|
||||
account2 = M3UAccount.objects.create(
|
||||
name="Second Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=self.group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||
profile2.max_streams = 1
|
||||
profile2.save()
|
||||
|
||||
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||
self.assertFalse(reserve_profile_slot(profile2, self.redis)[0])
|
||||
|
||||
fp = get_profile_credential_fingerprint(self.profile)
|
||||
cred_key = server_group_connections_key(self.group.id, fp)
|
||||
self.assertEqual(self.redis._data[cred_key], 1)
|
||||
|
||||
def test_different_logins_both_stream_in_same_group(self):
|
||||
"""Different provider logins keep separate credential counters in one group."""
|
||||
account = M3UAccount.objects.create(
|
||||
name="Grouped multi-login",
|
||||
account_type="XC",
|
||||
username="login_a",
|
||||
password="pass_a",
|
||||
server_group=self.group,
|
||||
max_streams=5,
|
||||
)
|
||||
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
default.max_streams = 1
|
||||
default.save()
|
||||
|
||||
alt = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt_login",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
with patch(
|
||||
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||
side_effect=lambda profile: fp_a if profile.id == default.id else fp_b,
|
||||
):
|
||||
self.assertTrue(reserve_profile_slot(default, self.redis)[0])
|
||||
self.assertTrue(reserve_profile_slot(alt, self.redis)[0])
|
||||
|
||||
key_a = server_group_connections_key(self.group.id, fp_a)
|
||||
key_b = server_group_connections_key(self.group.id, fp_b)
|
||||
self.assertEqual(self.redis._data[key_a], 1)
|
||||
self.assertEqual(self.redis._data[key_b], 1)
|
||||
|
||||
def test_no_fingerprint_skips_credential_counter(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="No creds",
|
||||
account_type="STD",
|
||||
server_group=self.group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
profile.max_streams = 1
|
||||
profile.save()
|
||||
|
||||
with patch(
|
||||
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||
return_value=None,
|
||||
):
|
||||
self.assertTrue(reserve_profile_slot(profile, self.redis)[0])
|
||||
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
|
||||
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
|
||||
|
||||
def test_release_when_profile_row_deleted(self):
|
||||
profile_id = self.profile.id
|
||||
fp = get_profile_credential_fingerprint(self.profile)
|
||||
cred_key = server_group_connections_key(self.group.id, fp)
|
||||
|
||||
reserved, _, failure_reason = reserve_profile_slot(
|
||||
self.profile, self.redis
|
||||
)
|
||||
self.assertTrue(reserved)
|
||||
self.assertIsNone(failure_reason)
|
||||
self.profile.delete()
|
||||
|
||||
release_profile_slot(profile_id, self.redis)
|
||||
|
||||
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
|
||||
self.assertEqual(self.redis._data[cred_key], 0)
|
||||
self.assertNotIn(profile_credential_release_key(profile_id), self.redis._data)
|
||||
|
||||
def test_release_uses_stored_credential_key_without_db_lookup(self):
|
||||
profile_id = self.profile.id
|
||||
cred_key = server_group_connections_key(
|
||||
self.group.id,
|
||||
get_profile_credential_fingerprint(self.profile),
|
||||
)
|
||||
|
||||
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||
|
||||
with patch("apps.m3u.models.M3UAccountProfile.objects.get") as mock_get:
|
||||
release_profile_slot(profile_id, self.redis)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
|
||||
self.assertEqual(self.redis._data[cred_key], 0)
|
||||
|
||||
def test_reserve_returns_failure_reason_without_extra_checks(self):
|
||||
account2 = M3UAccount.objects.create(
|
||||
name="Reason Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=self.group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
|
||||
profile2.max_streams = 1
|
||||
profile2.save()
|
||||
|
||||
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
|
||||
reserved, _count, reason = reserve_profile_slot(profile2, self.redis)
|
||||
self.assertFalse(reserved)
|
||||
self.assertEqual(reason, "credential_full")
|
||||
|
||||
|
||||
class StaleAssignmentTests(TestCase):
|
||||
def test_stale_assignment_releases_counters(self):
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
||||
redis = FakeRedis()
|
||||
group = ServerGroup.objects.create(name="stale-group")
|
||||
account = M3UAccount.objects.create(
|
||||
name="Stale Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
profile.max_streams = 1
|
||||
profile.save()
|
||||
|
||||
stream = Stream.objects.create(name="Stale Stream", m3u_account=account)
|
||||
channel = Channel.objects.create(channel_number=502, name="Stale Channel")
|
||||
channel.streams.add(stream)
|
||||
|
||||
reserve_profile_slot(profile, redis)
|
||||
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||
redis.set(f"stream_profile:{stream.id}", profile.id)
|
||||
|
||||
profile_key = profile_connections_key(profile.id)
|
||||
cred_key = server_group_connections_key(
|
||||
group.id, get_profile_credential_fingerprint(profile)
|
||||
)
|
||||
self.assertEqual(redis._data[profile_key], 1)
|
||||
self.assertEqual(redis._data[cred_key], 1)
|
||||
|
||||
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||
channel._release_stale_stream_assignment(redis, stream.id)
|
||||
|
||||
self.assertNotIn(f"channel_stream:{channel.id}", redis._data)
|
||||
self.assertNotIn(f"stream_profile:{stream.id}", redis._data)
|
||||
self.assertEqual(redis._data[profile_key], 0)
|
||||
self.assertEqual(redis._data[cred_key], 0)
|
||||
|
||||
|
||||
class UpdateStreamProfileTests(TestCase):
|
||||
def test_switch_updates_profile_counters_when_group_assigned(self):
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
||||
redis = FakeRedis()
|
||||
group = ServerGroup.objects.create(name="switch-group")
|
||||
account = M3UAccount.objects.create(
|
||||
name="Switch Account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
profile_a.max_streams = 1
|
||||
profile_a.save()
|
||||
profile_b = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
stream = Stream.objects.create(name="Test Stream", m3u_account=account)
|
||||
channel = Channel.objects.create(channel_number=501, name="Switch Channel")
|
||||
channel.streams.add(stream)
|
||||
|
||||
reserve_profile_slot(profile_a, redis)
|
||||
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||
redis.set(f"stream_profile:{stream.id}", profile_a.id)
|
||||
|
||||
cred_key = server_group_connections_key(
|
||||
group.id, get_profile_credential_fingerprint(profile_a)
|
||||
)
|
||||
cred_before = redis._data[cred_key]
|
||||
|
||||
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||
self.assertTrue(channel.update_stream_profile(profile_b.id))
|
||||
|
||||
self.assertEqual(int(redis.get(f"stream_profile:{stream.id}")), profile_b.id)
|
||||
self.assertEqual(redis._data[profile_connections_key(profile_a.id)], 0)
|
||||
self.assertEqual(redis._data[profile_connections_key(profile_b.id)], 1)
|
||||
self.assertEqual(redis._data[cred_key], cred_before)
|
||||
|
||||
def test_switch_moves_credential_counter_when_login_changes(self):
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
||||
redis = FakeRedis()
|
||||
group = ServerGroup.objects.create(name="cred-switch-group")
|
||||
account = M3UAccount.objects.create(
|
||||
name="Cred Switch Account",
|
||||
account_type="XC",
|
||||
username="login_a",
|
||||
password="pass_a",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
profile_a.max_streams = 1
|
||||
profile_a.save()
|
||||
profile_b = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt_login",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
key_a = server_group_connections_key(group.id, fp_a)
|
||||
key_b = server_group_connections_key(group.id, fp_b)
|
||||
|
||||
stream = Stream.objects.create(name="Cred Switch Stream", m3u_account=account)
|
||||
channel = Channel.objects.create(channel_number=503, name="Cred Switch Channel")
|
||||
channel.streams.add(stream)
|
||||
|
||||
with patch(
|
||||
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||
side_effect=lambda profile: fp_a if profile.id == profile_a.id else fp_b,
|
||||
):
|
||||
reserve_profile_slot(profile_a, redis)
|
||||
redis.set(f"channel_stream:{channel.id}", stream.id)
|
||||
redis.set(f"stream_profile:{stream.id}", profile_a.id)
|
||||
self.assertEqual(redis._data[key_a], 1)
|
||||
self.assertNotIn(key_b, redis._data)
|
||||
|
||||
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||
self.assertTrue(channel.update_stream_profile(profile_b.id))
|
||||
|
||||
self.assertEqual(redis._data[key_a], 0)
|
||||
self.assertEqual(redis._data[key_b], 1)
|
||||
|
||||
|
||||
class VodProfileSelectionTests(TestCase):
|
||||
def test_get_m3u_profile_skips_default_when_profile_full(self):
|
||||
from apps.proxy.vod_proxy.views import _get_m3u_profile
|
||||
|
||||
account = M3UAccount.objects.create(
|
||||
name="VOD multi-profile",
|
||||
account_type="XC",
|
||||
username="xc_user_a",
|
||||
password="xc_pass_a",
|
||||
server_url="http://xc.example.com",
|
||||
max_streams=1,
|
||||
)
|
||||
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
default.max_streams = 1
|
||||
default.save()
|
||||
|
||||
alt = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt_profile",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
redis = FakeRedis()
|
||||
self.assertTrue(reserve_profile_slot(default, redis)[0])
|
||||
|
||||
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||
result = _get_m3u_profile(account, None, None)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
selected, _connections = result
|
||||
self.assertEqual(selected.id, alt.id)
|
||||
|
||||
def test_get_m3u_profile_skips_default_when_credential_pool_full(self):
|
||||
from apps.proxy.vod_proxy.views import _get_m3u_profile
|
||||
|
||||
group = ServerGroup.objects.create(name="vod-cred-pool")
|
||||
account = M3UAccount.objects.create(
|
||||
name="VOD pooled",
|
||||
account_type="XC",
|
||||
username="shared_user",
|
||||
password="shared_pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
default.max_streams = 1
|
||||
default.save()
|
||||
|
||||
alt = M3UAccountProfile.objects.create(
|
||||
m3u_account=account,
|
||||
name="alt_login",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
max_streams=1,
|
||||
search_pattern="",
|
||||
replace_pattern="",
|
||||
)
|
||||
|
||||
other_account = M3UAccount.objects.create(
|
||||
name="Other pooled account",
|
||||
account_type="XC",
|
||||
username="shared_user",
|
||||
password="shared_pass",
|
||||
server_url="http://xc.example.com",
|
||||
server_group=group,
|
||||
max_streams=5,
|
||||
)
|
||||
other_profile = M3UAccountProfile.objects.get(
|
||||
m3u_account=other_account, is_default=True
|
||||
)
|
||||
other_profile.max_streams = 1
|
||||
other_profile.save()
|
||||
|
||||
fp_shared = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
fp_alt = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
|
||||
redis = FakeRedis()
|
||||
with patch(
|
||||
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
|
||||
side_effect=lambda profile: (
|
||||
fp_shared
|
||||
if profile.id in (default.id, other_profile.id)
|
||||
else fp_alt
|
||||
),
|
||||
):
|
||||
self.assertTrue(reserve_profile_slot(other_profile, redis)[0])
|
||||
|
||||
with patch("core.utils.RedisClient.get_client", return_value=redis):
|
||||
result = _get_m3u_profile(account, None, None)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
selected, _connections = result
|
||||
self.assertEqual(selected.id, alt.id)
|
||||
|
|
@ -33,6 +33,24 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase):
|
|||
process_m3u_batch_direct(1, [], {}, ["name", "url"])
|
||||
mock_connections.close_all.assert_called()
|
||||
|
||||
@patch("apps.m3u.tasks.Stream")
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_batch_calls_gc_collect(self, mock_account_cls, mock_stream_cls):
|
||||
"""gc.collect() must run after each batch so XC refresh threads release promptly."""
|
||||
from apps.m3u.tasks import process_m3u_batch_direct
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account.filters.order_by.return_value = []
|
||||
mock_account_cls.objects.get.return_value = mock_account
|
||||
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
|
||||
[]
|
||||
)
|
||||
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
|
||||
|
||||
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
|
||||
process_m3u_batch_direct(1, [], {}, ["name", "url"])
|
||||
mock_gc.assert_called()
|
||||
|
||||
|
||||
class LockReleaseTests(SimpleTestCase):
|
||||
"""Verify task lock is released on all exit paths."""
|
||||
|
|
|
|||
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.tasks import (
|
||||
_db_query_with_retry,
|
||||
_get_active_m3u_account,
|
||||
_release_task_db_connection,
|
||||
refresh_single_m3u_account,
|
||||
)
|
||||
|
||||
|
||||
class DbQueryWithRetryTests(SimpleTestCase):
|
||||
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||
|
||||
with patch(
|
||||
"apps.m3u.tasks._release_task_db_connection"
|
||||
) as mock_release:
|
||||
result = _db_query_with_retry(fn, label="test query")
|
||||
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
mock_release.assert_called_once()
|
||||
|
||||
def test_raises_after_exhausting_retries(self):
|
||||
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||
|
||||
with patch("apps.m3u.tasks._release_task_db_connection"):
|
||||
with self.assertRaises(IndexError):
|
||||
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
|
||||
|
||||
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||
@patch("apps.m3u.tasks._ensure_m3u_refresh_terminal_status")
|
||||
@patch("apps.m3u.tasks._refresh_single_m3u_account_impl")
|
||||
@patch("apps.m3u.tasks.release_task_lock")
|
||||
@patch("apps.m3u.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.m3u.tasks.TaskLockRenewer")
|
||||
@patch("apps.m3u.tasks._release_task_db_connection")
|
||||
def test_refresh_releases_db_connection_before_impl(
|
||||
self,
|
||||
mock_release,
|
||||
_mock_renewer,
|
||||
_mock_acquire,
|
||||
_mock_release_lock,
|
||||
mock_impl,
|
||||
_mock_ensure_terminal,
|
||||
):
|
||||
call_order = []
|
||||
|
||||
def track_release():
|
||||
call_order.append("release")
|
||||
|
||||
mock_release.side_effect = track_release
|
||||
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||
|
||||
result = refresh_single_m3u_account(140)
|
||||
|
||||
self.assertEqual(result, "done")
|
||||
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_get_active_m3u_account_uses_retry_helper(self, mock_model):
|
||||
mock_model.objects.get.return_value = MagicMock(is_active=True)
|
||||
|
||||
with patch("apps.m3u.tasks._db_query_with_retry") as mock_retry:
|
||||
mock_retry.side_effect = lambda fn, **_: fn()
|
||||
account = _get_active_m3u_account(140)
|
||||
|
||||
mock_retry.assert_called_once()
|
||||
self.assertTrue(account.is_active)
|
||||
240
apps/m3u/tests/test_sync_compound.py
Normal file
240
apps/m3u/tests/test_sync_compound.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
Compound-fixture sync tests.
|
||||
|
||||
Where individual sync tests cover one variation at a time (multi-stream,
|
||||
hidden, override, manual), this module seeds all of them in the same
|
||||
fixture and asserts the constraints still hold when sync sees the full
|
||||
mix on a single account. The point is to catch interactions that pass
|
||||
each isolated test but break when the conditions overlap.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import (
|
||||
Channel,
|
||||
ChannelGroup,
|
||||
ChannelGroupM3UAccount,
|
||||
ChannelOverride,
|
||||
ChannelStream,
|
||||
Stream,
|
||||
)
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.m3u.tasks import sync_auto_channels
|
||||
|
||||
|
||||
def _scan_start_time():
|
||||
return (timezone.now() - timedelta(minutes=1)).isoformat()
|
||||
|
||||
|
||||
class CompoundFixtureSyncTests(TestCase):
|
||||
"""
|
||||
Single fixture covers: multi-stream auto channel, hidden auto channel,
|
||||
auto channel with channel_number override, manual channel that shares
|
||||
the group with auto-created rows. After sync runs, every channel must
|
||||
end up in the state its individual test would have asserted.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="Compound Provider",
|
||||
server_url="http://example.com/compound.m3u",
|
||||
)
|
||||
self.group = ChannelGroup.objects.create(name="Compound Group")
|
||||
self.relation = ChannelGroupM3UAccount.objects.create(
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
enabled=True,
|
||||
auto_channel_sync=True,
|
||||
auto_sync_channel_start=100,
|
||||
auto_sync_channel_end=199,
|
||||
)
|
||||
now = timezone.now()
|
||||
|
||||
# ── Multi-stream auto channel: two streams, one fresh, one stale.
|
||||
# The channel must survive the stale stream's disappearance because
|
||||
# the fresh one keeps the channel alive.
|
||||
self.multi_stream_a = Stream.objects.create(
|
||||
name="MultiCh HD",
|
||||
url="http://example.com/multi-a.m3u8",
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
tvg_id="multi",
|
||||
last_seen=now,
|
||||
)
|
||||
self.multi_stream_b = Stream.objects.create(
|
||||
name="MultiCh HD",
|
||||
url="http://example.com/multi-b.m3u8",
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
tvg_id="multi",
|
||||
last_seen=now - timedelta(days=2),
|
||||
)
|
||||
self.multi_channel = Channel.objects.create(
|
||||
name="MultiCh HD",
|
||||
channel_number=100,
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=self.multi_channel, stream=self.multi_stream_a, order=0
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=self.multi_channel, stream=self.multi_stream_b, order=1
|
||||
)
|
||||
|
||||
# ── Hidden auto channel: visible from the table's perspective only
|
||||
# to admins with the Hidden filter on. Sync must not reuse its
|
||||
# channel_number for a different channel just because it's hidden.
|
||||
self.hidden_stream = Stream.objects.create(
|
||||
name="HiddenCh",
|
||||
url="http://example.com/hidden.m3u8",
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
tvg_id="hidden",
|
||||
last_seen=now,
|
||||
)
|
||||
self.hidden_channel = Channel.objects.create(
|
||||
name="HiddenCh",
|
||||
channel_number=101,
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
hidden_from_output=True,
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=self.hidden_channel, stream=self.hidden_stream, order=0
|
||||
)
|
||||
|
||||
# ── Overridden auto channel: user pinned channel_number to 150 via
|
||||
# an override row. Sync must not clobber the override; the
|
||||
# effective channel_number stays 150 even though the raw column
|
||||
# may evolve.
|
||||
self.overridden_stream = Stream.objects.create(
|
||||
name="OverriddenCh",
|
||||
url="http://example.com/overridden.m3u8",
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
tvg_id="overridden",
|
||||
last_seen=now,
|
||||
)
|
||||
self.overridden_channel = Channel.objects.create(
|
||||
name="OverriddenCh",
|
||||
channel_number=102,
|
||||
channel_group=self.group,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=self.overridden_channel,
|
||||
stream=self.overridden_stream,
|
||||
order=0,
|
||||
)
|
||||
ChannelOverride.objects.create(
|
||||
channel=self.overridden_channel,
|
||||
channel_number=150,
|
||||
name="My Pinned Name",
|
||||
)
|
||||
|
||||
# ── Manual channel sharing the group: auto_created=False, user
|
||||
# picked channel_number 175 themselves. Sync must not touch this
|
||||
# row at all.
|
||||
self.manual_channel = Channel.objects.create(
|
||||
name="ManualCh",
|
||||
channel_number=175,
|
||||
channel_group=self.group,
|
||||
auto_created=False,
|
||||
)
|
||||
|
||||
# New stream that has no existing channel; sync should create a
|
||||
# fresh channel for it within the configured range and skip
|
||||
# 100-102 (in use), 150 (overridden), 175 (manual).
|
||||
self.new_stream = Stream.objects.create(
|
||||
name="FreshCh",
|
||||
url="http://example.com/fresh.m3u8",
|
||||
m3u_account=self.account,
|
||||
channel_group=self.group,
|
||||
tvg_id="fresh",
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
def test_compound_fixture_each_invariant_holds_after_sync(self):
|
||||
sync_auto_channels(self.account.id, scan_start_time=_scan_start_time())
|
||||
|
||||
# Multi-stream channel survives.
|
||||
self.assertTrue(
|
||||
Channel.objects.filter(id=self.multi_channel.id).exists(),
|
||||
"Multi-stream channel was deleted even though one stream is alive",
|
||||
)
|
||||
|
||||
# Hidden channel survives and remains hidden.
|
||||
self.hidden_channel.refresh_from_db()
|
||||
self.assertTrue(self.hidden_channel.hidden_from_output)
|
||||
|
||||
# Overridden channel: the override row is intact, not cleared by
|
||||
# sync. The pinned channel_number persists.
|
||||
override = ChannelOverride.objects.get(channel=self.overridden_channel)
|
||||
self.assertEqual(override.channel_number, 150)
|
||||
self.assertEqual(override.name, "My Pinned Name")
|
||||
|
||||
# Manual channel is untouched.
|
||||
self.manual_channel.refresh_from_db()
|
||||
self.assertFalse(self.manual_channel.auto_created)
|
||||
self.assertEqual(self.manual_channel.channel_number, 175)
|
||||
|
||||
# The fresh stream becomes a new auto channel; its channel_number
|
||||
# falls inside the configured range and must not collide with any
|
||||
# existing number (100-102 used by the auto channels, 150 pinned
|
||||
# by the override, 175 the manual channel).
|
||||
fresh_stream_id = self.new_stream.id
|
||||
new_channel_qs = Channel.objects.filter(
|
||||
channelstream__stream_id=fresh_stream_id,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
).distinct()
|
||||
self.assertEqual(
|
||||
new_channel_qs.count(),
|
||||
1,
|
||||
"Sync did not create exactly one channel for the new stream",
|
||||
)
|
||||
new_number = new_channel_qs.first().channel_number
|
||||
self.assertIsNotNone(new_number)
|
||||
self.assertGreaterEqual(new_number, 100)
|
||||
self.assertLessEqual(new_number, 199)
|
||||
self.assertNotIn(new_number, {100, 101, 102, 150, 175})
|
||||
|
||||
def test_hidden_channel_number_not_reassigned_to_new_stream(self):
|
||||
# Targeted assertion isolated from the broader fixture invariants
|
||||
# so the failure mode is unambiguous when this single property
|
||||
# regresses.
|
||||
sync_auto_channels(self.account.id, scan_start_time=_scan_start_time())
|
||||
|
||||
new_channel = (
|
||||
Channel.objects.filter(
|
||||
channelstream__stream_id=self.new_stream.id,
|
||||
auto_created=True,
|
||||
auto_created_by=self.account,
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(new_channel)
|
||||
self.assertNotEqual(
|
||||
new_channel.channel_number,
|
||||
self.hidden_channel.channel_number,
|
||||
"Hidden channel's number was reassigned; hidden channels must "
|
||||
"still occupy their slot in the used_numbers set",
|
||||
)
|
||||
|
||||
def test_override_channel_number_preserved_through_sync(self):
|
||||
# If a future regression caused sync to write to ChannelOverride
|
||||
# (it must not), this test catches it because the override would
|
||||
# change after refresh.
|
||||
original_pin = 150
|
||||
sync_auto_channels(self.account.id, scan_start_time=_scan_start_time())
|
||||
|
||||
override = ChannelOverride.objects.get(channel=self.overridden_channel)
|
||||
self.assertEqual(override.channel_number, original_pin)
|
||||
2643
apps/m3u/tests/test_sync_correctness.py
Normal file
2643
apps/m3u/tests/test_sync_correctness.py
Normal file
File diff suppressed because it is too large
Load diff
139
apps/m3u/tests/test_xc_live_url.py
Normal file
139
apps/m3u/tests/test_xc_live_url.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Tests for XC stream URL normalization and on-demand URL building."""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.models import Stream
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.m3u.tasks import get_transformed_credentials
|
||||
from apps.proxy.live_proxy.url_utils import _resolve_live_stream_url
|
||||
from apps.vod.models import Episode, M3UEpisodeRelation, M3UMovieRelation, Movie, Series
|
||||
from core.xtream_codes import normalize_server_url
|
||||
|
||||
|
||||
class NormalizeServerUrlTests(TestCase):
|
||||
def test_preserves_sub_path(self):
|
||||
url = "https://myserver.fun/server1"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_strips_player_api_php_and_query_params(self):
|
||||
url = "https://myserver.fun/server1/player_api.php?username=foo&password=bar"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_strips_trailing_slash(self):
|
||||
url = "https://myserver.fun/server1/"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_nested_sub_path_with_php_endpoint(self):
|
||||
url = "http://server/Pluto/gb/player_api.php"
|
||||
self.assertEqual(normalize_server_url(url), "http://server/Pluto/gb")
|
||||
|
||||
|
||||
class GetTransformedCredentialsTests(TestCase):
|
||||
def test_returns_normalized_server_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="Sub-path XC",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
|
||||
server_url, username, password = get_transformed_credentials(account, profile)
|
||||
|
||||
self.assertEqual(server_url, "https://myserver.fun/server1")
|
||||
self.assertEqual(username, "alice")
|
||||
self.assertEqual(password, "secret")
|
||||
|
||||
|
||||
class ResolveLiveStreamUrlTests(TestCase):
|
||||
def test_builds_url_from_normalized_base_not_raw_account_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="Live sub-path",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
stream = Stream.objects.create(
|
||||
name="Test Channel",
|
||||
m3u_account=account,
|
||||
stream_id="12345",
|
||||
url="https://myserver.fun/server1/live/olduser/oldpass/12345.ts",
|
||||
)
|
||||
|
||||
url = _resolve_live_stream_url(stream, account, profile)
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/live/alice/secret/12345.ts",
|
||||
)
|
||||
|
||||
def test_std_account_uses_stored_stream_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="STD account",
|
||||
account_type="STD",
|
||||
server_url="https://example.com/list.m3u",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
stream = Stream.objects.create(
|
||||
name="STD Stream",
|
||||
m3u_account=account,
|
||||
url="https://provider.example/stream/abc123",
|
||||
)
|
||||
|
||||
url = _resolve_live_stream_url(stream, account, profile)
|
||||
|
||||
self.assertEqual(url, "https://provider.example/stream/abc123")
|
||||
|
||||
|
||||
class VodStreamUrlTests(TestCase):
|
||||
def setUp(self):
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="VOD sub-path",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
|
||||
def test_movie_relation_builds_normalized_url(self):
|
||||
movie = Movie.objects.create(name="Test Movie")
|
||||
relation = M3UMovieRelation.objects.create(
|
||||
m3u_account=self.account,
|
||||
movie=movie,
|
||||
stream_id="999",
|
||||
container_extension="mkv",
|
||||
)
|
||||
|
||||
url = relation.get_stream_url()
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/movie/alice/secret/999.mkv",
|
||||
)
|
||||
|
||||
def test_episode_relation_builds_normalized_url(self):
|
||||
series = Series.objects.create(name="Test Series")
|
||||
episode = Episode.objects.create(
|
||||
series=series,
|
||||
name="Pilot",
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
relation = M3UEpisodeRelation.objects.create(
|
||||
m3u_account=self.account,
|
||||
episode=episode,
|
||||
stream_id="888",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
url = relation.get_stream_url()
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/series/alice/secret/888.mp4",
|
||||
)
|
||||
1727
apps/output/epg.py
Normal file
1727
apps/output/epg.py
Normal file
File diff suppressed because it is too large
Load diff
239
apps/output/streaming_chunk_cache.py
Normal file
239
apps/output/streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Single-flight Redis chunk cache for large streaming HTTP responses."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_BUILDING = "building"
|
||||
STATUS_READY = "ready"
|
||||
STATUS_ERROR = "error"
|
||||
|
||||
DEFAULT_CACHE_TTL = 300
|
||||
DEFAULT_LOCK_TTL = 120
|
||||
DEFAULT_POLL_INTERVAL = 0.05
|
||||
DEFAULT_MAX_FOLLOWER_WAIT = 600
|
||||
|
||||
|
||||
def _chunks_key(base_key):
|
||||
return f"{base_key}:chunks"
|
||||
|
||||
|
||||
def _ready_key(base_key):
|
||||
return f"{base_key}:ready"
|
||||
|
||||
|
||||
def _status_key(base_key):
|
||||
return f"{base_key}:status"
|
||||
|
||||
|
||||
def _lock_key(base_key):
|
||||
return f"{base_key}:lock"
|
||||
|
||||
|
||||
def _decode_chunk(chunk):
|
||||
if chunk is None:
|
||||
return None
|
||||
if isinstance(chunk, bytes):
|
||||
return chunk.decode("utf-8")
|
||||
return chunk
|
||||
|
||||
|
||||
def _encode_chunk(chunk):
|
||||
if isinstance(chunk, bytes):
|
||||
return chunk
|
||||
return chunk.encode("utf-8")
|
||||
|
||||
|
||||
def _poll_wait(interval):
|
||||
try:
|
||||
from core.utils import _is_gevent_monkey_patched
|
||||
|
||||
if _is_gevent_monkey_patched():
|
||||
import gevent
|
||||
|
||||
gevent.sleep(interval)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def _get_redis():
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
return get_redis_connection("default")
|
||||
|
||||
|
||||
def _get_status(redis, base_key):
|
||||
raw = redis.get(_status_key(base_key))
|
||||
if raw is None:
|
||||
return None
|
||||
return _decode_chunk(raw)
|
||||
|
||||
|
||||
def _clear_build_keys(redis, base_key):
|
||||
redis.delete(
|
||||
_chunks_key(base_key),
|
||||
_status_key(base_key),
|
||||
_ready_key(base_key),
|
||||
_lock_key(base_key),
|
||||
)
|
||||
|
||||
|
||||
def _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
return bool(redis.set(_lock_key(base_key), "1", nx=True, ex=lock_ttl))
|
||||
|
||||
|
||||
def _refresh_build_ttl(redis, base_key, lock_ttl):
|
||||
redis.expire(_lock_key(base_key), lock_ttl)
|
||||
redis.expire(_status_key(base_key), lock_ttl)
|
||||
redis.expire(_chunks_key(base_key), lock_ttl)
|
||||
|
||||
|
||||
def _stream_ready(redis, base_key):
|
||||
offset = 0
|
||||
chunks_key = _chunks_key(base_key)
|
||||
while True:
|
||||
chunk = redis.lindex(chunks_key, offset)
|
||||
if chunk is None:
|
||||
break
|
||||
yield _decode_chunk(chunk)
|
||||
offset += 1
|
||||
|
||||
|
||||
def _stream_build(redis, base_key, source, cache_ttl, lock_ttl):
|
||||
"""Leader: stream to client and append each chunk to Redis."""
|
||||
chunks_key = _chunks_key(base_key)
|
||||
status_key = _status_key(base_key)
|
||||
try:
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
django_cache.delete(base_key) # clear any non-chunked entry under this key
|
||||
redis.delete(chunks_key, _ready_key(base_key))
|
||||
redis.set(status_key, STATUS_BUILDING, ex=lock_ttl)
|
||||
refresh_interval = max(1, lock_ttl // 4)
|
||||
last_refresh = 0.0
|
||||
for chunk in source():
|
||||
redis.rpush(chunks_key, _encode_chunk(chunk))
|
||||
now = time.monotonic()
|
||||
if now - last_refresh >= refresh_interval:
|
||||
_refresh_build_ttl(redis, base_key, lock_ttl)
|
||||
last_refresh = now
|
||||
yield chunk
|
||||
redis.set(status_key, STATUS_READY)
|
||||
redis.set(_ready_key(base_key), "1")
|
||||
redis.expire(chunks_key, cache_ttl)
|
||||
redis.expire(status_key, cache_ttl)
|
||||
redis.expire(_ready_key(base_key), cache_ttl)
|
||||
logger.debug("Cached response in %s chunks", redis.llen(chunks_key))
|
||||
except Exception:
|
||||
logger.exception("Chunk cache build failed for %s", base_key)
|
||||
redis.delete(chunks_key)
|
||||
redis.set(status_key, STATUS_ERROR, ex=60)
|
||||
raise
|
||||
finally:
|
||||
redis.delete(_lock_key(base_key))
|
||||
|
||||
|
||||
def _stream_follow(redis, base_key, source, cache_ttl, lock_ttl, poll_interval, max_follower_wait):
|
||||
"""Follower: read chunks as the leader writes them."""
|
||||
offset = 0
|
||||
deadline = time.monotonic() + max_follower_wait
|
||||
idle_polls = 0
|
||||
chunks_key = _chunks_key(base_key)
|
||||
lock_key = _lock_key(base_key)
|
||||
|
||||
while True:
|
||||
chunk = redis.lindex(chunks_key, offset)
|
||||
if chunk is not None:
|
||||
idle_polls = 0
|
||||
yield _decode_chunk(chunk)
|
||||
offset += 1
|
||||
continue
|
||||
|
||||
status = _get_status(redis, base_key)
|
||||
if status == STATUS_READY:
|
||||
break
|
||||
|
||||
if status == STATUS_ERROR:
|
||||
_clear_build_keys(redis, base_key)
|
||||
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
raise RuntimeError("Chunk cache build failed")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
logger.warning("Chunk cache follower timed out; rebuilding %s", base_key)
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
logger.warning("Chunk cache follower timed out after partial read for %s", base_key)
|
||||
break
|
||||
|
||||
lock_active = bool(redis.exists(lock_key))
|
||||
if status != STATUS_BUILDING and not lock_active:
|
||||
idle_polls += 1
|
||||
if offset == 0 and idle_polls >= max(1, int(1.0 / poll_interval)):
|
||||
if _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
logger.warning("Chunk cache leader lost; rebuilding %s", base_key)
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
else:
|
||||
idle_polls = 0
|
||||
|
||||
_poll_wait(poll_interval)
|
||||
|
||||
|
||||
def stream_cached_response(
|
||||
cache_key,
|
||||
source,
|
||||
*,
|
||||
content_type="application/xml",
|
||||
filename=None,
|
||||
cache_ttl=DEFAULT_CACHE_TTL,
|
||||
lock_ttl=DEFAULT_LOCK_TTL,
|
||||
poll_interval=DEFAULT_POLL_INTERVAL,
|
||||
max_follower_wait=DEFAULT_MAX_FOLLOWER_WAIT,
|
||||
redis=None,
|
||||
):
|
||||
"""
|
||||
Stream a large response with single-flight Redis chunk caching.
|
||||
|
||||
``source`` must be a callable returning a chunk iterator. Only the leader
|
||||
invokes it; concurrent followers replay chunks already written to Redis, so
|
||||
the expensive ``source`` runs at most once per ``cache_key``.
|
||||
"""
|
||||
if redis is None:
|
||||
redis = _get_redis()
|
||||
|
||||
if redis.get(_ready_key(cache_key)):
|
||||
logger.debug("Serving response from chunk cache")
|
||||
stream = _stream_ready(redis, cache_key)
|
||||
else:
|
||||
status = _get_status(redis, cache_key)
|
||||
if status == STATUS_ERROR:
|
||||
_clear_build_keys(redis, cache_key)
|
||||
|
||||
if _try_acquire_lock(redis, cache_key, lock_ttl):
|
||||
logger.debug("Building response (cache leader)")
|
||||
stream = _stream_build(redis, cache_key, source, cache_ttl, lock_ttl)
|
||||
else:
|
||||
logger.debug("Following in-flight cache build")
|
||||
stream = _stream_follow(
|
||||
redis,
|
||||
cache_key,
|
||||
source,
|
||||
cache_ttl,
|
||||
lock_ttl,
|
||||
poll_interval,
|
||||
max_follower_wait,
|
||||
)
|
||||
|
||||
response = StreamingHttpResponse(stream, content_type=content_type)
|
||||
if filename:
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
187
apps/output/test_streaming_chunk_cache.py
Normal file
187
apps/output/test_streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
|
||||
from apps.output.streaming_chunk_cache import (
|
||||
STATUS_BUILDING,
|
||||
STATUS_READY,
|
||||
_chunks_key,
|
||||
_lock_key,
|
||||
_ready_key,
|
||||
_status_key,
|
||||
stream_cached_response,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal Redis stand-in for chunk-cache unit tests."""
|
||||
|
||||
def __init__(self):
|
||||
self._strings = {}
|
||||
self._lists = {}
|
||||
self._expires_at = {}
|
||||
|
||||
def _purge_expired(self):
|
||||
now = time.monotonic()
|
||||
expired = [key for key, deadline in self._expires_at.items() if deadline <= now]
|
||||
for key in expired:
|
||||
self._strings.pop(key, None)
|
||||
self._lists.pop(key, None)
|
||||
self._expires_at.pop(key, None)
|
||||
|
||||
def get(self, key):
|
||||
self._purge_expired()
|
||||
return self._strings.get(key)
|
||||
|
||||
def set(self, key, value, nx=False, ex=None):
|
||||
self._purge_expired()
|
||||
if nx and key in self._strings:
|
||||
return None
|
||||
self._strings[key] = value
|
||||
if ex is not None:
|
||||
self._expires_at[key] = time.monotonic() + ex
|
||||
return True
|
||||
|
||||
def delete(self, *keys):
|
||||
for key in keys:
|
||||
self._strings.pop(key, None)
|
||||
self._lists.pop(key, None)
|
||||
self._expires_at.pop(key, None)
|
||||
|
||||
def exists(self, key):
|
||||
self._purge_expired()
|
||||
return key in self._strings or key in self._lists
|
||||
|
||||
def expire(self, key, ttl):
|
||||
if key in self._strings or key in self._lists:
|
||||
self._expires_at[key] = time.monotonic() + ttl
|
||||
return True
|
||||
|
||||
def rpush(self, key, value):
|
||||
self._lists.setdefault(key, []).append(value)
|
||||
|
||||
def lindex(self, key, offset):
|
||||
items = self._lists.get(key, [])
|
||||
if offset < len(items):
|
||||
return items[offset]
|
||||
return None
|
||||
|
||||
def llen(self, key):
|
||||
return len(self._lists.get(key, []))
|
||||
|
||||
|
||||
def _consume(response):
|
||||
return b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
|
||||
class StreamingChunkCacheTests(TestCase):
|
||||
def test_leader_caches_chunks_and_sets_ready(self):
|
||||
redis = FakeRedis()
|
||||
calls = []
|
||||
|
||||
def source():
|
||||
calls.append(1)
|
||||
yield "<tv>"
|
||||
yield "</tv>"
|
||||
|
||||
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
|
||||
self.assertEqual(body, "<tv></tv>")
|
||||
self.assertEqual(calls, [1])
|
||||
self.assertEqual(redis.get(_ready_key("cache:test")), "1")
|
||||
self.assertEqual(redis.get(_status_key("cache:test")), STATUS_READY)
|
||||
self.assertEqual(redis.llen(_chunks_key("cache:test")), 2)
|
||||
self.assertFalse(redis.exists(_lock_key("cache:test")))
|
||||
|
||||
def test_cache_hit_skips_source(self):
|
||||
redis = FakeRedis()
|
||||
calls = []
|
||||
|
||||
def source():
|
||||
calls.append(1)
|
||||
yield "<tv>"
|
||||
yield "</tv>"
|
||||
|
||||
_consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
calls.clear()
|
||||
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
|
||||
self.assertEqual(body, "<tv></tv>")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_follower_reads_leader_chunks_without_rebuilding(self):
|
||||
redis = FakeRedis()
|
||||
base = "cache:follow"
|
||||
leader_started = threading.Event()
|
||||
rebuild_calls = []
|
||||
|
||||
def slow_source():
|
||||
rebuild_calls.append(1)
|
||||
leader_started.set()
|
||||
yield "a"
|
||||
time.sleep(0.05)
|
||||
yield "b"
|
||||
|
||||
def forbidden_source():
|
||||
rebuild_calls.append(2)
|
||||
yield "SHOULD_NOT_RUN"
|
||||
|
||||
def leader():
|
||||
_consume(
|
||||
stream_cached_response(
|
||||
base,
|
||||
slow_source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
leader_thread = threading.Thread(target=leader)
|
||||
leader_thread.start()
|
||||
leader_started.wait(timeout=5)
|
||||
follower_body = _consume(
|
||||
stream_cached_response(
|
||||
base,
|
||||
forbidden_source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
leader_thread.join(timeout=5)
|
||||
|
||||
self.assertEqual(follower_body, "ab")
|
||||
self.assertEqual(rebuild_calls, [1])
|
||||
|
||||
def test_only_one_leader_when_two_clients_start_together(self):
|
||||
redis = FakeRedis()
|
||||
build_calls = []
|
||||
barrier = threading.Barrier(2)
|
||||
results = {}
|
||||
|
||||
def source():
|
||||
build_calls.append(threading.current_thread().name)
|
||||
yield "x"
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
results[threading.current_thread().name] = _consume(
|
||||
stream_cached_response(
|
||||
"cache:race",
|
||||
source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, name="t1"),
|
||||
threading.Thread(target=worker, name="t2"),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
|
||||
self.assertEqual(results["t1"], "x")
|
||||
self.assertEqual(results["t2"], "x")
|
||||
self.assertEqual(len(build_calls), 1)
|
||||
|
|
@ -1,31 +1,131 @@
|
|||
from django.test import TestCase, Client
|
||||
from django.test import TestCase, Client, SimpleTestCase, RequestFactory
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel, ChannelGroup
|
||||
from unittest import skipUnless
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from apps.channels.models import Channel, ChannelGroup, ChannelProfile, ChannelProfileMembership
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
from apps.accounts.models import User
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.output.views import xc_get_series, xc_get_vod_streams
|
||||
from apps.vod.models import (
|
||||
M3UMovieRelation,
|
||||
M3USeriesRelation,
|
||||
Movie,
|
||||
Series,
|
||||
VODCategory,
|
||||
VODLogo,
|
||||
)
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def _response_text(response):
|
||||
"""Read body from HttpResponse or StreamingHttpResponse."""
|
||||
if getattr(response, "streaming", False):
|
||||
return b"".join(response.streaming_content).decode()
|
||||
return response.content.decode()
|
||||
|
||||
|
||||
def _epg_response_without_redis(cache_key, source, **kwargs):
|
||||
"""Test helper: stream EPG directly without Redis chunk caching."""
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
response = StreamingHttpResponse(source(), content_type="application/xml")
|
||||
response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
class OutputEndpointTestMixin:
|
||||
"""Isolate HTTP endpoint tests from network ACL, logging, DB teardown, and Redis."""
|
||||
|
||||
class OutputM3UTest(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._network_patch = patch(
|
||||
"apps.output.views.network_access_allowed",
|
||||
return_value=True,
|
||||
)
|
||||
self._epg_teardown_patch = patch("apps.output.epg._epg_export_teardown")
|
||||
self._log_event_patch = patch("apps.output.views.log_system_event")
|
||||
self._epg_log_event_patch = patch("apps.output.epg.log_system_event")
|
||||
self._close_db_patch = patch("django.db.close_old_connections")
|
||||
self._epg_cache_patch = patch(
|
||||
"apps.output.epg.stream_cached_response",
|
||||
side_effect=_epg_response_without_redis,
|
||||
)
|
||||
self._network_patch.start()
|
||||
self._epg_teardown_patch.start()
|
||||
self._log_event_patch.start()
|
||||
self._epg_log_event_patch.start()
|
||||
self._close_db_patch.start()
|
||||
self._epg_cache_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
self._epg_cache_patch.stop()
|
||||
self._close_db_patch.stop()
|
||||
self._epg_log_event_patch.stop()
|
||||
self._log_event_patch.stop()
|
||||
self._epg_teardown_patch.stop()
|
||||
self._network_patch.stop()
|
||||
super().tearDown()
|
||||
|
||||
def _create_isolated_profile(self, prefix):
|
||||
"""New profiles auto-include every channel via signal; clear that for tests."""
|
||||
profile = ChannelProfile.objects.create(name=f"{prefix}-{uuid4().hex[:8]}")
|
||||
ChannelProfileMembership.objects.filter(channel_profile=profile).delete()
|
||||
return profile
|
||||
|
||||
def _add_channel_to_profile(self, profile, group, **kwargs):
|
||||
channel = Channel.objects.create(channel_group=group, **kwargs)
|
||||
ChannelProfileMembership.objects.create(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True,
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
class OutputM3UTest(OutputEndpointTestMixin, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = Client()
|
||||
|
||||
self.group = ChannelGroup.objects.create(name=f"M3U Group {uuid4().hex[:8]}")
|
||||
self.profile = self._create_isolated_profile("m3u")
|
||||
self._add_channel_to_profile(
|
||||
self.profile,
|
||||
self.group,
|
||||
channel_number=1.0,
|
||||
name="Test M3U Channel",
|
||||
)
|
||||
|
||||
def _m3u_url(self):
|
||||
return reverse("output:m3u_endpoint", kwargs={"profile_name": self.profile.name})
|
||||
|
||||
def test_generate_m3u_response(self):
|
||||
"""
|
||||
Test that the M3U endpoint returns a valid M3U file.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._m3u_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn("#EXTM3U", content)
|
||||
|
||||
def test_generate_m3u_response_post_empty_body(self):
|
||||
"""
|
||||
Test that a POST request with an empty body returns 200 OK.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
|
||||
response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded')
|
||||
content = response.content.decode()
|
||||
response = self.client.post(
|
||||
self._m3u_url(),
|
||||
data=None,
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
content = _response_text(response)
|
||||
|
||||
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
|
||||
self.assertIn("#EXTM3U", content)
|
||||
|
|
@ -34,35 +134,40 @@ class OutputM3UTest(TestCase):
|
|||
"""
|
||||
Test that a POST request with a non-empty body returns 403 Forbidden.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
|
||||
response = self.client.post(url, data={'evilstring': 'muhahaha'})
|
||||
response = self.client.post(self._m3u_url(), data={"evilstring": "muhahaha"})
|
||||
|
||||
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
|
||||
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
|
||||
self.assertIn("POST requests with body are not allowed", _response_text(response))
|
||||
|
||||
|
||||
class OutputEPGXMLEscapingTest(TestCase):
|
||||
class OutputEPGXMLEscapingTest(OutputEndpointTestMixin, TestCase):
|
||||
"""Test XML escaping of channel_id attributes in EPG generation"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = Client()
|
||||
self.group = ChannelGroup.objects.create(name="Test Group")
|
||||
self.group = ChannelGroup.objects.create(name=f"Test Group {uuid4().hex[:8]}")
|
||||
self.profile = self._create_isolated_profile("epg-xml")
|
||||
|
||||
def _add_channel(self, **kwargs):
|
||||
return self._add_channel_to_profile(self.profile, self.group, **kwargs)
|
||||
|
||||
def _epg_url(self, query="tvg_id_source=tvg_id&days=0&prev_days=0"):
|
||||
base = reverse("output:epg_endpoint", kwargs={"profile_name": self.profile.name})
|
||||
return f"{base}?{query}"
|
||||
|
||||
def test_channel_id_with_ampersand(self):
|
||||
"""Test channel ID with ampersand is properly escaped"""
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=1.0,
|
||||
name="Test Channel",
|
||||
tvg_id="News & Sports",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
|
||||
# Should contain escaped ampersand
|
||||
self.assertIn('id="News & Sports"', content)
|
||||
|
|
@ -76,17 +181,15 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
|
||||
def test_channel_id_with_angle_brackets(self):
|
||||
"""Test channel ID with < and > characters"""
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=2.0,
|
||||
name="HD Channel",
|
||||
tvg_id="Channel <HD>",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn('id="Channel <HD>"', content)
|
||||
|
||||
try:
|
||||
|
|
@ -96,23 +199,28 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
|
||||
def test_channel_id_with_all_special_chars(self):
|
||||
"""Test channel ID with all XML special characters"""
|
||||
channel = Channel.objects.create(
|
||||
expected_id = 'Test & "Special" <Chars>'
|
||||
self._add_channel(
|
||||
channel_number=3.0,
|
||||
name="Complex Channel",
|
||||
tvg_id='Test & "Special" <Chars>',
|
||||
channel_group=self.group
|
||||
tvg_id=expected_id,
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn('id="Test & "Special" <Chars>"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
# Verify we can find the channel with correct ID in parsed tree
|
||||
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
|
||||
channel_elem = next(
|
||||
(
|
||||
elem
|
||||
for elem in tree.findall(".//channel")
|
||||
if elem.get("id") == expected_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(channel_elem)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
|
||||
|
|
@ -121,25 +229,670 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
"""Test that programme elements also have escaped channel attributes"""
|
||||
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
|
||||
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=4.0,
|
||||
name="Program Test",
|
||||
tvg_id="News & Sports",
|
||||
epg_data=epg_data,
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
|
||||
# Check programme elements have escaped channel attributes
|
||||
self.assertIn('channel="News & Sports"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
programmes = tree.findall('.//programme[@channel="News & Sports"]')
|
||||
programmes = [
|
||||
programme
|
||||
for programme in tree.findall(".//programme")
|
||||
if programme.get("channel") == "News & Sports"
|
||||
]
|
||||
self.assertGreater(len(programmes), 0)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")
|
||||
|
||||
def test_programmes_emitted_in_start_time_order(self):
|
||||
"""Programmes for a channel are emitted in start_time order, not insert order."""
|
||||
from django.utils import timezone
|
||||
from apps.epg.models import ProgramData
|
||||
|
||||
epg_source = EPGSource.objects.create(name="Real EPG", source_type="xmltv")
|
||||
epg_data = EPGData.objects.create(name="Station", epg_source=epg_source, tvg_id="station1")
|
||||
self._add_channel(
|
||||
channel_number=149.0,
|
||||
name="Food Network",
|
||||
tvg_id="station1",
|
||||
epg_data=epg_data,
|
||||
)
|
||||
now = timezone.now()
|
||||
# Insert out of chronological order so id order != start_time order.
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=3),
|
||||
end_time=now + timedelta(days=3, hours=1),
|
||||
title="Third",
|
||||
tvg_id="station1",
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=1),
|
||||
end_time=now + timedelta(days=1, hours=1),
|
||||
title="First",
|
||||
tvg_id="station1",
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=2),
|
||||
end_time=now + timedelta(days=2, hours=1),
|
||||
title="Second",
|
||||
tvg_id="station1",
|
||||
)
|
||||
|
||||
content = _response_text(self.client.get(self._epg_url("tvg_id_source=tvg_id&days=7")))
|
||||
|
||||
self.assertLess(content.find('<title>First</title>'), content.find('<title>Second</title>'))
|
||||
self.assertLess(content.find('<title>Second</title>'), content.find('<title>Third</title>'))
|
||||
|
||||
|
||||
class OutputEPGCustomDummyTest(TestCase):
|
||||
"""Custom dummy EPG must not fall back to default when pattern matched but event is outside window."""
|
||||
|
||||
def setUp(self):
|
||||
self.group = ChannelGroup.objects.create(name="Sports Group")
|
||||
|
||||
def test_custom_dummy_outside_window_fills_with_ended_programmes(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.views import generate_dummy_programs
|
||||
|
||||
epg_source = EPGSource.objects.create(
|
||||
name="NHL Dummy",
|
||||
source_type="dummy",
|
||||
custom_properties={
|
||||
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
|
||||
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
|
||||
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
|
||||
"timezone": "US/Eastern",
|
||||
"program_duration": 180,
|
||||
},
|
||||
)
|
||||
channel_name = (
|
||||
"NHL 01: Washington Capitals vs Philadelphia Flyers @ April 16 07:30 PM ET"
|
||||
)
|
||||
now = timezone.now()
|
||||
lookback = now - timedelta(days=7)
|
||||
|
||||
programs = generate_dummy_programs(
|
||||
channel_id="nhl01",
|
||||
channel_name=channel_name,
|
||||
num_days=7,
|
||||
epg_source=epg_source,
|
||||
export_lookback=lookback,
|
||||
export_cutoff=now + timedelta(days=7),
|
||||
)
|
||||
|
||||
self.assertGreater(len(programs), 0)
|
||||
self.assertTrue(
|
||||
all(p['end_time'] >= lookback for p in programs),
|
||||
"All programmes should fall inside the export window",
|
||||
)
|
||||
self.assertTrue(
|
||||
any('Ended' in p['description'] for p in programs),
|
||||
"Past events outside the window should still show ended filler",
|
||||
)
|
||||
for program in programs:
|
||||
start = program['start_time']
|
||||
self.assertEqual(start.second, 0)
|
||||
self.assertEqual(start.microsecond, 0)
|
||||
self.assertIn(
|
||||
start.minute, (0, 30),
|
||||
"Filler programmes should start on half-hour boundaries",
|
||||
)
|
||||
self.assertGreaterEqual(programs[0]['start_time'], lookback)
|
||||
|
||||
def test_custom_dummy_future_event_fills_grid_window_with_upcoming(self):
|
||||
"""Grid-style window: future event should show upcoming filler, not empty."""
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _programme_overlaps_export_window, generate_dummy_programs
|
||||
|
||||
epg_source = EPGSource.objects.create(
|
||||
name="NHL Dummy Future",
|
||||
source_type="dummy",
|
||||
custom_properties={
|
||||
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
|
||||
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
|
||||
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
|
||||
"timezone": "US/Eastern",
|
||||
"program_duration": 180,
|
||||
},
|
||||
)
|
||||
now = timezone.now()
|
||||
grid_start = now - timedelta(hours=1)
|
||||
grid_end = now + timedelta(hours=24)
|
||||
future = now + timedelta(days=3)
|
||||
channel_name = (
|
||||
f"NHL 01: Washington Capitals vs Philadelphia Flyers @ "
|
||||
f"{future.strftime('%B')} {future.day} 07:30 PM ET"
|
||||
)
|
||||
|
||||
programs = generate_dummy_programs(
|
||||
channel_id="nhl01",
|
||||
channel_name=channel_name,
|
||||
num_days=1,
|
||||
epg_source=epg_source,
|
||||
export_lookback=grid_start,
|
||||
export_cutoff=grid_end,
|
||||
)
|
||||
|
||||
self.assertGreater(len(programs), 0)
|
||||
self.assertTrue(
|
||||
all(
|
||||
_programme_overlaps_export_window(
|
||||
p["start_time"], p["end_time"], grid_start, grid_end
|
||||
)
|
||||
for p in programs
|
||||
),
|
||||
"All programmes should overlap the grid query window",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("Upcoming" in p.get("description", "") for p in programs),
|
||||
"Future events outside the window should show upcoming filler",
|
||||
)
|
||||
|
||||
|
||||
class OutputEPGHelperTest(SimpleTestCase):
|
||||
def test_ceil_to_half_hour_on_boundary(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=30, second=0, microsecond=0)
|
||||
self.assertEqual(_ceil_to_half_hour(dt), dt)
|
||||
|
||||
def test_ceil_to_half_hour_rounds_up(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=17, second=42, microsecond=123456)
|
||||
aligned = _ceil_to_half_hour(dt)
|
||||
self.assertEqual(aligned.minute, 30)
|
||||
self.assertEqual(aligned.second, 0)
|
||||
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
|
||||
|
||||
def test_ceil_to_half_hour_past_boundary_second(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=0, second=52, microsecond=123456)
|
||||
aligned = _ceil_to_half_hour(dt)
|
||||
self.assertEqual(aligned.minute, 30)
|
||||
self.assertEqual(aligned.second, 0)
|
||||
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
|
||||
|
||||
|
||||
class XcVodSeriesDistinctTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.request = self.factory.get("/player_api.php")
|
||||
|
||||
def _account(self, name, *, priority=0, is_active=True):
|
||||
return M3UAccount.objects.create(
|
||||
name=name,
|
||||
server_url="http://example.com",
|
||||
priority=priority,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
def test_vod_streams_picks_highest_priority_relation(self):
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
movie = Movie.objects.create(name="Shared Movie", year=2020)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=low,
|
||||
movie=movie,
|
||||
stream_id="low-stream",
|
||||
container_extension="mkv",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=high,
|
||||
movie=movie,
|
||||
stream_id="high-stream",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["name"], "Shared Movie")
|
||||
self.assertEqual(streams[0]["container_extension"], "mp4")
|
||||
|
||||
def test_vod_streams_excludes_inactive_accounts(self):
|
||||
active = self._account(f"active-{uuid4().hex[:6]}", priority=1)
|
||||
inactive = self._account(
|
||||
f"inactive-{uuid4().hex[:6]}", priority=99, is_active=False
|
||||
)
|
||||
active_movie = Movie.objects.create(name="Active Movie")
|
||||
inactive_movie = Movie.objects.create(name="Inactive Only Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=active,
|
||||
movie=active_movie,
|
||||
stream_id="active-1",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=inactive,
|
||||
movie=inactive_movie,
|
||||
stream_id="inactive-1",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
names = {s["name"] for s in streams}
|
||||
self.assertEqual(names, {"Active Movie"})
|
||||
|
||||
def test_vod_streams_category_filter(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
action = VODCategory.objects.create(name="Action", category_type="movie")
|
||||
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
|
||||
action_movie = Movie.objects.create(name="Action Movie")
|
||||
comedy_movie = Movie.objects.create(name="Comedy Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=action_movie,
|
||||
category=action,
|
||||
stream_id="action-1",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=comedy_movie,
|
||||
category=comedy,
|
||||
stream_id="comedy-1",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user, category_id=action.id)
|
||||
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["name"], "Action Movie")
|
||||
self.assertEqual(streams[0]["category_id"], str(action.id))
|
||||
|
||||
def test_vod_streams_sorted_alphabetically_by_name(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
zebra = Movie.objects.create(name="Zebra Film")
|
||||
apple = Movie.objects.create(name="Apple Film")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=zebra, stream_id="z-1"
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=apple, stream_id="a-1"
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
self.assertEqual([s["name"] for s in streams], ["Apple Film", "Zebra Film"])
|
||||
|
||||
def test_vod_streams_includes_metadata_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(
|
||||
name="Rich Movie",
|
||||
description="A plot",
|
||||
genre="Drama",
|
||||
year=2021,
|
||||
rating="8",
|
||||
custom_properties={
|
||||
"director": "Dir",
|
||||
"actors": "Cast",
|
||||
"release_date": "2021-01-01",
|
||||
"youtube_trailer": "yt123",
|
||||
},
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="rich-1",
|
||||
container_extension="avi",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["plot"], "A plot")
|
||||
self.assertEqual(stream["genre"], "Drama")
|
||||
self.assertEqual(stream["year"], 2021)
|
||||
self.assertEqual(stream["director"], "Dir")
|
||||
self.assertEqual(stream["cast"], "Cast")
|
||||
self.assertEqual(stream["release_date"], "2021-01-01")
|
||||
self.assertEqual(stream["trailer"], "yt123")
|
||||
self.assertEqual(stream["container_extension"], "avi")
|
||||
|
||||
def test_vod_streams_stream_icon_uses_logo_id_without_logo_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
logo = VODLogo.objects.create(name="Poster", url="http://example.com/poster.png")
|
||||
movie = Movie.objects.create(name="Logo Movie", logo=logo)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="logo-1",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertIn(f"/{logo.id}/", stream["stream_icon"])
|
||||
|
||||
def test_series_picks_highest_priority_relation(self):
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
series = Series.objects.create(name="Shared Series", year=2019)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=low,
|
||||
series=series,
|
||||
external_series_id="low-series",
|
||||
)
|
||||
high_rel = M3USeriesRelation.objects.create(
|
||||
m3u_account=high,
|
||||
series=series,
|
||||
external_series_id="high-series",
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["name"], "Shared Series")
|
||||
self.assertEqual(results[0]["series_id"], high_rel.id)
|
||||
|
||||
def test_series_excludes_inactive_accounts(self):
|
||||
active = self._account(f"active-{uuid4().hex[:6]}")
|
||||
inactive = self._account(f"inactive-{uuid4().hex[:6]}", is_active=False)
|
||||
active_series = Series.objects.create(name="Active Series")
|
||||
inactive_series = Series.objects.create(name="Inactive Only Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=active,
|
||||
series=active_series,
|
||||
external_series_id="active-s",
|
||||
)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=inactive,
|
||||
series=inactive_series,
|
||||
external_series_id="inactive-s",
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual({r["name"] for r in results}, {"Active Series"})
|
||||
|
||||
def test_series_sorted_alphabetically_by_name(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
z = Series.objects.create(name="Zulu Show")
|
||||
a = Series.objects.create(name="Alpha Show")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=z, external_series_id="z"
|
||||
)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=a, external_series_id="a"
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual([r["name"] for r in results], ["Alpha Show", "Zulu Show"])
|
||||
|
||||
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
|
||||
def test_vod_streams_dedupe_query_avoids_movie_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Query Shape Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=movie, stream_id="qs-1"
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
|
||||
self.assertEqual(len(distinct_queries), 1)
|
||||
self.assertNotIn('"vod_movie"', distinct_queries[0]["sql"])
|
||||
self.assertNotIn('"vod_vodlogo"', distinct_queries[0]["sql"])
|
||||
|
||||
fetch_queries = [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if '"vod_movie"' in q["sql"] and "DISTINCT" not in q["sql"]
|
||||
]
|
||||
self.assertGreaterEqual(len(fetch_queries), 1)
|
||||
fetch_sql = fetch_queries[0]["sql"]
|
||||
self.assertNotIn('"vod_vodlogo"', fetch_sql)
|
||||
self.assertNotIn('"vod_vodcategory"', fetch_sql)
|
||||
|
||||
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
|
||||
def test_series_dedupe_query_avoids_series_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Query Shape Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=series, external_series_id="qs-s"
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
xc_get_series(self.request, self.user)
|
||||
|
||||
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
|
||||
self.assertEqual(len(distinct_queries), 1)
|
||||
self.assertNotIn('"vod_series"', distinct_queries[0]["sql"])
|
||||
|
||||
fetch_queries = [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if '"vod_series"' in q["sql"] and "DISTINCT" not in q["sql"]
|
||||
]
|
||||
self.assertGreaterEqual(len(fetch_queries), 1)
|
||||
fetch_sql = fetch_queries[0]["sql"]
|
||||
self.assertNotIn('"vod_vodlogo"', fetch_sql)
|
||||
self.assertNotIn('"vod_vodcategory"', fetch_sql)
|
||||
|
||||
|
||||
XC_VOD_STREAM_KEYS = frozenset({
|
||||
"num", "name", "stream_type", "stream_id", "stream_icon", "rating",
|
||||
"rating_5based", "added", "is_adult", "tmdb_id", "imdb_id", "trailer",
|
||||
"plot", "genre", "year", "director", "cast", "release_date", "category_id",
|
||||
"category_ids", "container_extension", "custom_sid", "direct_source",
|
||||
})
|
||||
|
||||
XC_SERIES_KEYS = frozenset({
|
||||
"num", "name", "series_id", "cover", "plot", "cast", "director", "genre",
|
||||
"release_date", "releaseDate", "last_modified", "rating", "rating_5based",
|
||||
"backdrop_path", "youtube_trailer", "episode_run_time", "category_id",
|
||||
"category_ids", "tmdb_id", "imdb_id",
|
||||
})
|
||||
|
||||
|
||||
class XcVodSeriesRegressionTests(TestCase):
|
||||
"""Full output-shape and edge-case regressions for XC list endpoints."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-reg-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.request = self.factory.get("/player_api.php")
|
||||
|
||||
def _account(self, name, *, priority=0):
|
||||
return M3UAccount.objects.create(
|
||||
name=name,
|
||||
server_url="http://example.com",
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def test_vod_streams_empty_library(self):
|
||||
self.assertEqual(xc_get_vod_streams(self.request, self.user), [])
|
||||
|
||||
def test_series_empty_library(self):
|
||||
self.assertEqual(xc_get_series(self.request, self.user), [])
|
||||
|
||||
def test_vod_streams_response_keys(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Schema Movie", rating="10")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=movie, stream_id="schema-1"
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(set(stream.keys()), XC_VOD_STREAM_KEYS)
|
||||
self.assertEqual(stream["stream_type"], "movie")
|
||||
self.assertEqual(stream["stream_id"], movie.id)
|
||||
self.assertEqual(stream["rating_5based"], 5.0)
|
||||
self.assertEqual(stream["custom_sid"], None)
|
||||
self.assertEqual(stream["direct_source"], "")
|
||||
|
||||
def test_vod_streams_null_optional_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Sparse Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="sparse-1",
|
||||
container_extension=None,
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertIsNone(stream["stream_icon"])
|
||||
self.assertEqual(stream["category_id"], "0")
|
||||
self.assertEqual(stream["category_ids"], [])
|
||||
self.assertEqual(stream["container_extension"], "mp4")
|
||||
self.assertEqual(stream["plot"], "")
|
||||
self.assertEqual(stream["trailer"], "")
|
||||
self.assertEqual(stream["tmdb_id"], "")
|
||||
self.assertEqual(stream["imdb_id"], "")
|
||||
|
||||
def test_vod_streams_category_from_winning_relation(self):
|
||||
"""Category must come from the highest-priority relation, not any relation."""
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
action = VODCategory.objects.create(name="Action", category_type="movie")
|
||||
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
|
||||
movie = Movie.objects.create(name="Dual Category Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=low,
|
||||
movie=movie,
|
||||
category=action,
|
||||
stream_id="low-cat",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=high,
|
||||
movie=movie,
|
||||
category=comedy,
|
||||
stream_id="high-cat",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["category_id"], str(comedy.id))
|
||||
self.assertEqual(stream["category_ids"], [comedy.id])
|
||||
|
||||
def test_series_response_keys_and_metadata(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
logo = VODLogo.objects.create(name="Cover", url="http://example.com/cover.png")
|
||||
category = VODCategory.objects.create(name="Drama", category_type="series")
|
||||
series = Series.objects.create(
|
||||
name="Schema Series",
|
||||
description="Series plot",
|
||||
genre="Sci-Fi",
|
||||
year=2022,
|
||||
rating="8",
|
||||
tmdb_id="tm123",
|
||||
imdb_id="tt123",
|
||||
logo=logo,
|
||||
custom_properties={
|
||||
"cast": "Actor A",
|
||||
"director": "Director B",
|
||||
"release_date": "2022-06-01",
|
||||
"backdrop_path": ["/img1.jpg"],
|
||||
"youtube_trailer": "yt-series",
|
||||
"episode_run_time": "45",
|
||||
},
|
||||
)
|
||||
relation = M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
category=category,
|
||||
external_series_id="schema-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(set(row.keys()), XC_SERIES_KEYS)
|
||||
self.assertEqual(row["series_id"], relation.id)
|
||||
self.assertIn(f"/{logo.id}/", row["cover"])
|
||||
self.assertEqual(row["plot"], "Series plot")
|
||||
self.assertEqual(row["cast"], "Actor A")
|
||||
self.assertEqual(row["director"], "Director B")
|
||||
self.assertEqual(row["genre"], "Sci-Fi")
|
||||
self.assertEqual(row["release_date"], "2022-06-01")
|
||||
self.assertEqual(row["releaseDate"], "2022-06-01")
|
||||
self.assertEqual(row["backdrop_path"], ["/img1.jpg"])
|
||||
self.assertEqual(row["youtube_trailer"], "yt-series")
|
||||
self.assertEqual(row["episode_run_time"], "45")
|
||||
self.assertEqual(row["tmdb_id"], "tm123")
|
||||
self.assertEqual(row["imdb_id"], "tt123")
|
||||
self.assertEqual(row["category_id"], str(category.id))
|
||||
self.assertEqual(row["category_ids"], [category.id])
|
||||
self.assertEqual(row["last_modified"], str(int(relation.updated_at.timestamp())))
|
||||
|
||||
def test_series_null_optional_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Sparse Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
external_series_id="sparse-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertIsNone(row["cover"])
|
||||
self.assertEqual(row["category_id"], "0")
|
||||
self.assertEqual(row["category_ids"], [])
|
||||
self.assertEqual(row["release_date"], "")
|
||||
self.assertEqual(row["releaseDate"], "")
|
||||
self.assertEqual(row["backdrop_path"], [])
|
||||
self.assertEqual(row["youtube_trailer"], "")
|
||||
self.assertEqual(row["episode_run_time"], "")
|
||||
|
||||
def test_series_release_date_falls_back_to_year(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Year Only", year=2018)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
external_series_id="year-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(row["release_date"], "2018")
|
||||
self.assertEqual(row["releaseDate"], "2018")
|
||||
|
||||
def test_priority_tiebreaker_uses_lower_relation_id(self):
|
||||
"""Same priority: DISTINCT ON tie-breaks on relation id ascending."""
|
||||
a1 = self._account(f"a1-{uuid4().hex[:6]}", priority=5)
|
||||
a2 = self._account(f"a2-{uuid4().hex[:6]}", priority=5)
|
||||
movie = Movie.objects.create(name="Tie Movie")
|
||||
first = M3UMovieRelation.objects.create(
|
||||
m3u_account=a1,
|
||||
movie=movie,
|
||||
stream_id="first",
|
||||
container_extension="mkv",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=a2,
|
||||
movie=movie,
|
||||
stream_id="second",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["container_extension"], first.container_extension)
|
||||
|
|
|
|||
2456
apps/output/views.py
2456
apps/output/views.py
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,14 @@ from .api_views import (
|
|||
PluginImportAPIView,
|
||||
PluginDeleteAPIView,
|
||||
PluginLogoAPIView,
|
||||
PluginRepoListCreateAPIView,
|
||||
PluginRepoPreviewAPIView,
|
||||
PluginRepoDetailAPIView,
|
||||
PluginRepoRefreshAPIView,
|
||||
AvailablePluginsAPIView,
|
||||
PluginDetailManifestAPIView,
|
||||
PluginInstallFromRepoAPIView,
|
||||
PluginRepoSettingsAPIView,
|
||||
)
|
||||
|
||||
app_name = "plugins"
|
||||
|
|
@ -21,4 +29,13 @@ urlpatterns = [
|
|||
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
|
||||
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
|
||||
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
|
||||
# Plugin repos (hub / store) - static paths first, then parametric
|
||||
path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"),
|
||||
path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"),
|
||||
path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"),
|
||||
path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"),
|
||||
path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"),
|
||||
path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"),
|
||||
path("repos/<int:pk>/", PluginRepoDetailAPIView.as_view(), name="repo-detail"),
|
||||
path("repos/<int:pk>/refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"),
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,12 @@ class PluginsConfig(AppConfig):
|
|||
|
||||
- Skip during common management commands that don't need discovery.
|
||||
- Register post_migrate handler to sync plugin registry to DB after migrations.
|
||||
- Do an in-memory discovery (no DB) so registry is available early.
|
||||
- Run in-memory discovery (no DB) in every non-Celery process so plugin
|
||||
modules are imported and monkey-patches apply. This includes uWSGI workers
|
||||
under lazy-apps=true, which each start cold and never inherit a warmed fork.
|
||||
Celery workers skip here and discover via the worker_ready signal instead.
|
||||
- One-shot startup tasks (schedule setup, repo refresh) are gated by
|
||||
should_skip_initialization() so they only fire in the master/main process.
|
||||
"""
|
||||
try:
|
||||
# Allow explicit opt-out via env var
|
||||
|
|
@ -44,11 +49,70 @@ class PluginsConfig(AppConfig):
|
|||
dispatch_uid="apps.plugins.post_migrate_discover",
|
||||
)
|
||||
|
||||
# Perform non-DB discovery now to populate in-memory registry.
|
||||
from .loader import PluginManager
|
||||
PluginManager.get().discover_plugins(sync_db=False)
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
# Skip discovery for Celery (worker_ready signal handles it) and
|
||||
# pure management commands that don't serve requests. Every other
|
||||
# process - including each uWSGI worker under lazy-apps=true - runs
|
||||
# discovery so plugin modules are imported and monkey-patches apply.
|
||||
_no_discovery_cmds = {'celery', 'beat', 'migrate', 'dbshell', 'loaddata'}
|
||||
if not any(cmd in sys.argv for cmd in _no_discovery_cmds):
|
||||
from .loader import PluginManager
|
||||
PluginManager.get().discover_plugins(sync_db=False)
|
||||
|
||||
if should_skip_initialization():
|
||||
return
|
||||
except Exception:
|
||||
# Avoid breaking startup due to plugin errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")
|
||||
|
||||
# Register periodic task for refreshing plugin repo manifests
|
||||
self._setup_repo_refresh_schedule()
|
||||
|
||||
# Refresh repo manifests once at startup so the UI always has current data
|
||||
self._enqueue_startup_refresh()
|
||||
|
||||
def _enqueue_startup_refresh(self):
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
if should_skip_initialization():
|
||||
return
|
||||
try:
|
||||
from .tasks import refresh_plugin_repos
|
||||
refresh_plugin_repos.apply_async(countdown=10)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"Could not enqueue startup plugin repo refresh (Celery may not be ready yet)"
|
||||
)
|
||||
|
||||
def _setup_repo_refresh_schedule(self):
|
||||
from dispatcharr.app_initialization import should_skip_initialization
|
||||
if should_skip_initialization():
|
||||
return
|
||||
try:
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.models import CoreSettings
|
||||
from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME
|
||||
|
||||
interval = 6
|
||||
try:
|
||||
obj = CoreSettings.objects.get(key="plugin_repo_settings")
|
||||
interval = obj.value.get("refresh_interval_hours", 6)
|
||||
except CoreSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
if interval == 0:
|
||||
delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME)
|
||||
else:
|
||||
create_or_update_periodic_task(
|
||||
task_name=PLUGIN_REPO_REFRESH_TASK_NAME,
|
||||
celery_task_path="apps.plugins.tasks.refresh_plugin_repos",
|
||||
interval_hours=interval,
|
||||
enabled=True,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
|
||||
)
|
||||
|
|
|
|||
11
apps/plugins/keys/dispatcharr-plugins.pub
Normal file
11
apps/plugins/keys/dispatcharr-plugins.pub
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h
|
||||
zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv
|
||||
Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap
|
||||
MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy
|
||||
LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ
|
||||
AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb
|
||||
yDvGuoZi3I2/GNM/f2qVzFtgPQk=
|
||||
=Zf/y
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
|
|
@ -8,9 +8,9 @@ import sys
|
|||
import threading
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import close_old_connections, transaction
|
||||
|
||||
from .models import PluginConfig
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ class PluginManager:
|
|||
self._alias_names: Dict[str, str] = {}
|
||||
self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
|
||||
self._last_reload_token = 0.0
|
||||
self._discovery_completed = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
|
|
@ -71,7 +72,7 @@ class PluginManager:
|
|||
token = self._get_reload_token()
|
||||
if use_cache and not force_reload:
|
||||
with self._lock:
|
||||
if self._registry and token <= self._last_reload_token:
|
||||
if self._discovery_completed and token <= self._last_reload_token:
|
||||
return self._registry
|
||||
if token > self._last_reload_token:
|
||||
force_reload = True
|
||||
|
|
@ -91,6 +92,29 @@ class PluginManager:
|
|||
key: lp.path for key, lp in self._registry.items() if lp and lp.path
|
||||
}
|
||||
|
||||
try:
|
||||
return self._discover_plugins_impl(
|
||||
sync_db=sync_db,
|
||||
force_reload=force_reload,
|
||||
previous_packages=previous_packages,
|
||||
previous_aliases=previous_aliases,
|
||||
previous_paths=previous_paths,
|
||||
token=token,
|
||||
)
|
||||
finally:
|
||||
# Discovery runs outside Django's request/task cycle (boot, worker_ready).
|
||||
close_old_connections()
|
||||
|
||||
def _discover_plugins_impl(
|
||||
self,
|
||||
*,
|
||||
sync_db: bool,
|
||||
force_reload: bool,
|
||||
previous_packages: Dict[str, str],
|
||||
previous_aliases: Dict[str, str],
|
||||
previous_paths: Dict[str, str],
|
||||
token: int,
|
||||
) -> Dict[str, LoadedPlugin]:
|
||||
try:
|
||||
configs: Optional[Dict[str, PluginConfig]] = None
|
||||
try:
|
||||
|
|
@ -231,6 +255,7 @@ class PluginManager:
|
|||
self._alias_names = new_aliases
|
||||
if token > self._last_reload_token:
|
||||
self._last_reload_token = token
|
||||
self._discovery_completed = True
|
||||
|
||||
logger.info(f"Discovered {len(new_registry)} plugin(s)")
|
||||
except FileNotFoundError:
|
||||
|
|
@ -245,6 +270,23 @@ class PluginManager:
|
|||
logger.exception("Deferring plugin DB sync; database not ready yet")
|
||||
return self._registry
|
||||
|
||||
def iter_actions_for_event(self, event_name: str) -> Iterator[Tuple[str, str]]:
|
||||
"""Yield (plugin_key, action_id) pairs from the in-memory registry."""
|
||||
with self._lock:
|
||||
registry = list(self._registry.items())
|
||||
for key, lp in registry:
|
||||
for action in lp.actions or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
action_id = action.get("id")
|
||||
events = action.get("events")
|
||||
if (
|
||||
action_id
|
||||
and isinstance(events, (list, tuple))
|
||||
and event_name in events
|
||||
):
|
||||
yield key, action_id
|
||||
|
||||
def _load_plugin(
|
||||
self,
|
||||
key: str,
|
||||
|
|
@ -367,21 +409,35 @@ class PluginManager:
|
|||
obj.save()
|
||||
|
||||
def list_plugins(self) -> List[Dict[str, Any]]:
|
||||
from .models import PluginConfig
|
||||
from .models import PluginConfig, PluginRepo
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
registry_snapshot = dict(self._registry)
|
||||
try:
|
||||
configs = {c.key: c for c in PluginConfig.objects.all()}
|
||||
configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()}
|
||||
except Exception as e:
|
||||
# Database might not be migrated yet; fall back to registry only
|
||||
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
|
||||
configs = {}
|
||||
|
||||
# Build repo latest-version lookup from cached manifests
|
||||
repo_latest = {} # slug -> latest_version
|
||||
try:
|
||||
for repo in PluginRepo.objects.filter(enabled=True):
|
||||
manifest_data = repo.cached_manifest or {}
|
||||
manifest = manifest_data.get("manifest", manifest_data)
|
||||
for rp in manifest.get("plugins", []):
|
||||
s = rp.get("slug", "")
|
||||
if s:
|
||||
repo_latest[s] = rp.get("latest_version", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First, include all discovered plugins
|
||||
for key, lp in registry_snapshot.items():
|
||||
conf = configs.get(key)
|
||||
conf_slug = conf.slug if conf else ""
|
||||
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
|
||||
logo_url = self._get_logo_url(key, path=lp.path)
|
||||
plugins.append(
|
||||
|
|
@ -393,7 +449,7 @@ class PluginManager:
|
|||
"author": getattr(lp, "author", "") or "",
|
||||
"help_url": getattr(lp, "help_url", "") or "",
|
||||
"enabled": conf.enabled if conf else False,
|
||||
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
|
||||
"ever_enabled": conf.ever_enabled if conf else False,
|
||||
"fields": lp.fields or [],
|
||||
"settings": (conf.settings if conf else {}),
|
||||
"actions": lp.actions or [],
|
||||
|
|
@ -402,6 +458,22 @@ class PluginManager:
|
|||
"loaded": bool(lp.loaded),
|
||||
"legacy": bool(getattr(lp, "legacy", False)),
|
||||
"logo_url": logo_url,
|
||||
"source_repo": conf.source_repo_id if conf else None,
|
||||
"source_repo_name": conf.source_repo.name if conf and conf.source_repo else None,
|
||||
"is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official),
|
||||
"slug": conf_slug,
|
||||
"is_managed": bool(conf and conf.source_repo_id),
|
||||
"installed_version_is_prerelease": bool(
|
||||
conf and conf.installed_version_is_prerelease
|
||||
),
|
||||
"update_available": bool(
|
||||
conf_slug and conf and conf.source_repo_id
|
||||
and not (conf and conf.installed_version_is_prerelease)
|
||||
and repo_latest.get(conf_slug)
|
||||
and lp.version != repo_latest.get(conf_slug)
|
||||
),
|
||||
"latest_version": repo_latest.get(conf_slug, ""),
|
||||
"deprecated": conf.deprecated if conf else False,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -428,6 +500,22 @@ class PluginManager:
|
|||
"loaded": False,
|
||||
"legacy": False,
|
||||
"logo_url": self._get_logo_url(key),
|
||||
"source_repo": conf.source_repo_id,
|
||||
"source_repo_name": conf.source_repo.name if conf.source_repo else None,
|
||||
"is_official_repo": bool(conf.source_repo and conf.source_repo.is_official),
|
||||
"slug": conf.slug,
|
||||
"is_managed": bool(conf.source_repo_id),
|
||||
"installed_version_is_prerelease": bool(
|
||||
conf.installed_version_is_prerelease
|
||||
),
|
||||
"update_available": bool(
|
||||
conf.slug and conf.source_repo_id
|
||||
and not conf.installed_version_is_prerelease
|
||||
and repo_latest.get(conf.slug)
|
||||
and conf.version != repo_latest.get(conf.slug)
|
||||
),
|
||||
"latest_version": repo_latest.get(conf.slug or "", ""),
|
||||
"deprecated": conf.deprecated,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -444,79 +532,85 @@ class PluginManager:
|
|||
return cfg.settings
|
||||
|
||||
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
||||
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
||||
try:
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
raise ValueError(f"Plugin '{key}' not found")
|
||||
# Attempt a lightweight re-discovery in case the registry was rebuilt
|
||||
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
raise ValueError(f"Plugin '{key}' not found")
|
||||
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
if not cfg.enabled:
|
||||
raise PermissionError(f"Plugin '{key}' is disabled")
|
||||
params = params or {}
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
if not cfg.enabled:
|
||||
raise PermissionError(f"Plugin '{key}' is disabled")
|
||||
params = params or {}
|
||||
|
||||
context = self._build_context(lp, cfg)
|
||||
context = self._build_context(lp, cfg)
|
||||
|
||||
# Run either via Celery if plugin provides a delayed method, or inline
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
if not callable(run_method):
|
||||
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
if not callable(run_method):
|
||||
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
|
||||
|
||||
try:
|
||||
result = run_method(action_id, params, context)
|
||||
except Exception:
|
||||
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
|
||||
raise
|
||||
try:
|
||||
result = run_method(action_id, params, context)
|
||||
except Exception:
|
||||
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
|
||||
raise
|
||||
|
||||
# Normalize return
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"status": "ok", "result": result}
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"status": "ok", "result": result}
|
||||
finally:
|
||||
# Return geventpool checkouts for this greenlet/thread after every action,
|
||||
# including Connect event hooks and manual UI runs.
|
||||
close_old_connections()
|
||||
|
||||
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
return False
|
||||
try:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
except PluginConfig.DoesNotExist:
|
||||
return False
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
|
||||
context = self._build_context(lp, cfg)
|
||||
if reason:
|
||||
context["reason"] = reason
|
||||
|
||||
stop_method = getattr(lp.instance, "stop", None)
|
||||
if callable(stop_method):
|
||||
lp = self.get_plugin(key)
|
||||
if not lp or not lp.instance:
|
||||
return False
|
||||
try:
|
||||
stop_method(context)
|
||||
return True
|
||||
except TypeError:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
except PluginConfig.DoesNotExist:
|
||||
return False
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
|
||||
context = self._build_context(lp, cfg)
|
||||
if reason:
|
||||
context["reason"] = reason
|
||||
|
||||
stop_method = getattr(lp.instance, "stop", None)
|
||||
if callable(stop_method):
|
||||
try:
|
||||
stop_method()
|
||||
stop_method(context)
|
||||
return True
|
||||
except TypeError:
|
||||
try:
|
||||
stop_method()
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop() failed", key)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop() failed", key)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop() failed", key)
|
||||
return False
|
||||
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
if callable(run_method):
|
||||
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
||||
if "stop" in actions:
|
||||
try:
|
||||
run_method("stop", {}, context)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop action failed", key)
|
||||
return False
|
||||
return False
|
||||
run_method = getattr(lp.instance, "run", None)
|
||||
if callable(run_method):
|
||||
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
|
||||
if "stop" in actions:
|
||||
try:
|
||||
run_method("stop", {}, context)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Plugin '%s' stop action failed", key)
|
||||
return False
|
||||
return False
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
|
||||
stopped = 0
|
||||
|
|
|
|||
84
apps/plugins/migrations/0002_pluginrepo.py
Normal file
84
apps/plugins/migrations/0002_pluginrepo.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_official_repo(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.get_or_create(
|
||||
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json",
|
||||
defaults={
|
||||
"name": "Dispatcharr Official",
|
||||
"is_official": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed_official_repo(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.filter(is_official=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("plugins", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PluginRepo",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("url", models.URLField(unique=True)),
|
||||
("is_official", models.BooleanField(default=False)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("cached_manifest", models.JSONField(blank=True, default=dict)),
|
||||
("last_fetched", models.DateTimeField(blank=True, null=True)),
|
||||
("public_key", models.TextField(blank=True, default="")),
|
||||
("signature_verified", models.BooleanField(blank=True, default=None, null=True)),
|
||||
("last_fetch_status", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-is_official", "name"],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(seed_official_repo, unseed_official_repo),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="source_repo",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="installed_plugins",
|
||||
to="plugins.pluginrepo",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="slug",
|
||||
field=models.CharField(blank=True, default="", max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="installed_version_is_prerelease",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pluginconfig",
|
||||
name="deprecated",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue