Compare commits
743 commits
v2.9.0-bet
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
1144bba580 |
||
|
443eb8da15 |
||
|
5ecd570f95 |
||
|
b8589513c1 |
||
|
d46710962c |
||
|
5921a7d83f |
||
|
9a6253d775 |
||
|
43fc7eebfe |
||
|
c4f8a81190 |
||
|
f6bffe1850 |
||
|
3cb71f94a3 |
||
|
ff5edc06fe |
||
|
cc88cffc1f |
||
|
e735294e1c |
||
|
889026b092 |
||
|
76f6a2da6b |
||
|
d2a14ea6c0 |
||
|
e6c0a12dd5 |
||
|
24dd403a72 |
||
|
a876e006d6 |
||
|
74786f0ed1 |
||
|
99e575383c |
||
|
3e784c7495 |
||
|
68dc095c83 |
||
|
ad2ec0e2bf |
||
|
09c28e434d |
||
|
cfc7b817b3 |
||
|
b3aa29c677 |
||
|
e4d181ba5b |
||
|
53e5f89725 |
||
|
0879b848b9 |
||
|
c70381c3ff |
||
|
f23d3eb81c |
||
|
2ed603f288 |
||
|
a96fd23d72 |
||
|
65dc466c07 |
||
|
0a4730625c |
||
|
67fa4ca645 |
||
|
078c293bd7 |
||
|
85e9237608 |
||
|
f9b3631745 |
||
|
8f03e27617 |
||
|
63fe386057 |
||
|
b7c4f2eefe |
||
|
37ef098718 |
||
|
78864d7a97 |
||
|
62a05712f8 |
||
|
ca0e1c321d |
||
|
b9cb7102c4 |
||
|
6e6fe1fb65 |
||
|
9c473c6528 |
||
|
5c38de0dfb |
||
|
ea66f6713b |
||
|
dd9a35df51 |
||
|
feca713b76 |
||
|
0836fb902c |
||
|
eb2c372d82 |
||
|
be2e63e7e0 |
||
|
2fe3f039cc |
||
|
baf926e5db |
||
|
85b63fb61a |
||
|
afc29604cc |
||
|
5b47cebdc7 |
||
|
d9f38f9390 |
||
|
86d775a586 |
||
|
ddb4f6131b |
||
|
599e52de6a |
||
|
84be60cb36 |
||
|
d9a87f9726 |
||
|
9289ead996 |
||
|
af752e0acc |
||
|
86abd130b0 |
||
|
fc2c7cc871 |
||
|
025e8bcf58 |
||
|
bf07912711 |
||
|
48b1c7b522 |
||
|
e69852fa0e |
||
|
01589cb8b0 |
||
|
f3a2c02e96 |
||
|
d3f7eef84f |
||
|
2f3d24a0e7 |
||
|
2d3271376b |
||
|
940c2ae6cd |
||
|
1cdfd5f30a |
||
|
e3f4851883 |
||
|
1353247b55 |
||
|
3cf6560de3 |
||
|
9ca8d59372 |
||
|
921a3a0af9 |
||
|
3bb53f480e |
||
|
6979a4025f |
||
|
cc1a325eac |
||
|
de697cb2ca |
||
|
596cf57d61 |
||
|
ac32297160 |
||
|
330b8a3a82 |
||
|
5cf39cb097 |
||
|
14c9c7a393 |
||
|
cf8fb2e65d |
||
|
623a9f2919 |
||
|
3fb46a9ab7 |
||
|
cfd81684b7 |
||
|
fb4f0046f3 |
||
|
7d4efac75d |
||
|
509d18801b |
||
|
da501df846 |
||
|
43cb027592 |
||
|
2e6f541ec2 |
||
|
822d5a452c |
||
|
7696d031d3 |
||
|
50ced86ba5 |
||
|
e934d09eff |
||
|
96c5cb216c |
||
|
2ee2ab652c |
||
|
193b82c54a |
||
|
7d00383d1c |
||
|
6f84ce8048 |
||
|
709db66b10 |
||
|
2f1607b96b |
||
|
28ad2716ba |
||
|
f1a8164b94 |
||
|
3bc94cad6c |
||
|
a528f052b9 |
||
|
5e977c044a |
||
|
afa25d45f6 |
||
|
43e71d836a |
||
|
55573d26ea |
||
|
f1d44c051d |
||
|
a3af8ed362 |
||
|
912fd75a2f |
||
|
5778672dab |
||
|
dcdf5a2992 |
||
|
73cfa8e0c0 |
||
|
795d568df2 |
||
|
8396a04ce8 |
||
|
8419eee4b2 |
||
|
c505e26656 |
||
|
37ffe68ce2 |
||
|
dc9e778111 |
||
|
68bf1c70f7 |
||
|
ee0b4c0602 |
||
|
5c115dec68 |
||
|
1d77f32665 |
||
|
af01b8c6cc |
||
|
dd9d3b97a2 |
||
|
96c20ad893 |
||
|
5e90f3bb31 |
||
|
dab46249f2 |
||
|
5d0ba8b222 |
||
|
3e8a5663a3 |
||
|
6414a0ba12 |
||
|
bcac5b7897 |
||
|
de3393d62b |
||
|
dcec1f6f5f |
||
|
65905a6647 |
||
|
5de2cf85c3 |
||
|
a7660d5c03 |
||
|
5e02db897f |
||
|
52e2950aa9 |
||
|
acf1b2384a |
||
|
07166ae6b2 |
||
|
80984bd296 |
||
|
6a9e532805 |
||
|
2258a88168 |
||
|
282810e9ca |
||
|
4582ff4a56 |
||
|
10e62ca42d |
||
|
e3245bc126 |
||
|
3dc6d226f8 |
||
|
177962d626 |
||
|
5e4f656155 |
||
|
980fc8a43f |
||
|
458e89b8d7 |
||
|
b8185afdf9 |
||
|
85519b1b45 |
||
|
653a6d5c12 |
||
|
b2a8601c70 |
||
|
938b48c5aa |
||
|
cdfffda877 |
||
|
b6eca379fa |
||
|
aa2006c2cc |
||
|
26358427ce |
||
|
1d96e0f859 |
||
|
0d1d2a3e6b |
||
|
452a4afdcf |
||
|
bfc4f66739 |
||
|
f82aecb88c |
||
|
75a1750a4e |
||
|
51196a7fb1 |
||
|
2fc618c01f |
||
|
fcd8ef11f4 |
||
|
c737161164 |
||
|
de3121cba9 |
||
|
9fe58a6d86 |
||
|
cfdb6975f0 |
||
|
41693ee5f1 |
||
|
7a11b10947 |
||
|
4430c14374 |
||
|
e248c13c15 |
||
|
b01b21ae05 |
||
|
6c6fa34ba4 |
||
|
24fff60ed4 |
||
|
4398dfa821 |
||
|
a0170a6f3d |
||
|
faef9a94c4 |
||
|
cfefa928be |
||
|
aca7e72715 |
||
|
b7836102a9 |
||
|
13eb0fd6db |
||
|
a4cfc6323d |
||
|
4468c3e4af |
||
|
52819f7da6 |
||
|
24b6d37bbe |
||
|
dbffb519f5 |
||
|
e307796475 |
||
|
c1b8be0227 |
||
|
d3504e8a3c |
||
|
344f19c9d6 |
||
|
901c484f89 |
||
|
149c7fa7a0 |
||
|
b73a2e9acc |
||
|
b48e9f4074 |
||
|
36fbff5503 |
||
|
355be99512 |
||
|
a397b90c23 |
||
|
6a19beb476 |
||
|
71e6ea00c9 |
||
|
7e4f7c0c56 |
||
|
36ed4bfa3c |
||
|
7ee2c59075 |
||
|
82089fdb7b |
||
|
cc070cfc6b |
||
|
78a7a48587 |
||
|
f403fdcc5b |
||
|
50dba6bf19 |
||
|
23591a0435 |
||
|
60f29b0fa4 |
||
|
b1c0972077 |
||
|
040972bcba |
||
|
c172965ec8 |
||
|
d0c07326ab |
||
|
d019efcf91 |
||
|
fe7a59c7f9 |
||
|
e3113ebd30 |
||
|
5525b9851c |
||
|
8cb74f7480 |
||
|
98ceb0a81d |
||
|
325271a88e |
||
|
ddc8a08fc7 |
||
|
d0c1e467bd |
||
|
380cbd232c |
||
|
98c363f559 |
||
|
5abdfd7377 |
||
|
8fd62e30b3 |
||
|
89aad6952b |
||
|
2da3714dd1 |
||
|
ab5836a65b |
||
|
ae17d2dde0 |
||
|
f1c12c0bbe |
||
|
32cf26884b |
||
|
dd380b583f |
||
|
c215afbf84 |
||
|
d63c0cb469 |
||
|
98583d139a |
||
|
ab16adcffc |
||
|
a31dcb5508 |
||
|
1e4fc05ecf |
||
|
efdd4156d8 |
||
|
7245e97726 |
||
|
d8d1f75605 |
||
|
b32183b7b6 |
||
|
a59e07c07d |
||
|
aa4d98ee34 |
||
|
fdc1dd3525 |
||
|
62be48df9c |
||
|
982c893c49 |
||
|
0fa7553d97 |
||
|
b18c31f431 |
||
|
a668932ea8 |
||
|
59fe34982e |
||
|
4938954c61 |
||
|
76f1335a55 |
||
|
5e75d0ce73 |
||
|
26419f4610 |
||
|
aaea924aaa |
||
|
6d858367a4 |
||
|
85bc9c39ae |
||
|
80e6131a0d |
||
|
1b26775ec6 |
||
|
fa510792f1 |
||
|
289a8a2334 |
||
|
47b2f55c97 |
||
|
38435ae81c |
||
|
b133904b48 |
||
|
67842cfa02 |
||
|
24fbc9a17a |
||
|
b2c16eba07 |
||
|
2c42150799 |
||
|
3debeada2a |
||
|
d0c7f25a3f |
||
|
371d35433c |
||
|
4033114175 |
||
|
9423f65a90 |
||
|
72f1ce7865 |
||
|
9383d5120c |
||
|
69d052f758 |
||
|
c0aa4e4996 |
||
|
9f00f5dafa |
||
|
7266a60343 |
||
|
edd8c5fdc3 |
||
|
d6b3ed178e |
||
|
eac78a3047 |
||
|
c93f470371 |
||
|
77f38bbf93 | ||
|
1e903b164b |
||
|
9a196f3dca |
||
|
6b6d43ef43 |
||
|
478d9e6aa5 |
||
|
c5bbaaf39c |
||
|
e70e08c3f5 |
||
|
1798594569 |
||
|
70fb00280b |
||
|
835ea34bea |
||
|
a21fffd227 |
||
|
f80cd73982 |
||
|
e11a4c50ba |
||
|
a84b5b51ed |
||
|
b7c0b104e9 |
||
|
6fa8bb3768 |
||
|
31543d267f |
||
|
842e36485a |
||
|
e2cb15ef49 |
||
|
b984a99d51 |
||
|
d701d18a81 |
||
|
765804c93b |
||
|
b953b951fb |
||
|
571a6b6d2d |
||
|
6010e406c8 |
||
|
d91e561a56 |
||
|
343a3e9281 |
||
|
b144e6527f |
||
|
1fe6d1505f |
||
|
085cfa4bef |
||
|
c761e6e8d0 |
||
|
7ff3abe8b7 |
||
|
d9b3b311b9 |
||
|
2a48e3375a |
||
|
ea6c6078df |
||
|
f39b9f9087 |
||
|
3a1d6322ae |
||
|
fe4fba353e |
||
|
3b3c59c4bb |
||
|
e9b1db139e |
||
|
99afb7392b |
||
|
14648a4604 |
||
|
07715c6a49 |
||
|
b0921b5f4a |
||
|
2921c1fc30 |
||
|
fa8b51bfd9 |
||
|
eb7a4fb4bf |
||
|
18110206d6 |
||
|
7836dc8403 |
||
|
ab7e559f39 |
||
|
710dd7955f |
||
|
93081e3f6d |
||
|
8664570a78 |
||
|
e5f55d7fe7 |
||
|
41f935e1ed |
||
|
ea5b43419d |
||
|
2dd9272f56 |
||
|
705fd2d3e8 |
||
|
93d1f10ad3 |
||
|
eb7495e930 |
||
|
b7da2dedf3 |
||
|
000656be00 |
||
|
c90469b0b7 |
||
|
3d2959ee51 |
||
|
4274a29bd6 |
||
|
22c5e59ee9 |
||
|
39047ea7c3 |
||
|
0925234f06 |
||
|
d240984c27 |
||
|
8f06b39a07 |
||
|
aa7606d576 |
||
|
78a3d15039 |
||
|
ec323abb92 |
||
|
69e1b43aa1 |
||
|
dffa353966 |
||
|
a241de6dc2 |
||
|
3ba3ea9b7e |
||
|
33f9c70181 |
||
|
eb9c9ffd4e |
||
|
133c8a3363 |
||
|
66fa93cfaf |
||
|
d0f3f96709 |
||
|
8e42757b2d |
||
|
ded93ef2f5 |
||
|
1466a391d1 |
||
|
6b1b6d0f32 |
||
|
2fda916331 |
||
|
32c06a8b72 |
||
|
6910079330 |
||
|
9f727d0086 |
||
|
cc78f17be5 |
||
|
70e09582da |
||
|
1f59171dcb |
||
|
c8f43825f9 |
||
|
eaa294b2a2 |
||
|
5c43daf067 |
||
|
0522253194 |
||
|
ae3d75bbe3 |
||
|
993909fa08 |
||
|
ce45321e3f |
||
|
cd3ff6eed7 |
||
|
731d5c9baf |
||
|
af3e5574f5 |
||
|
42eeb90532 |
||
|
ecb6d8b743 |
||
|
e6d1712afd |
||
|
ebe570d42f |
||
|
2a1bf7847b |
||
|
928e1d4b5e |
||
|
c5005c1ea9 |
||
|
b2b12044e3 |
||
|
b1dd28e39b |
||
|
c2abfce8e1 |
||
|
6807cebe51 |
||
|
87d3c0ae81 |
||
|
6b0b3a476f |
||
|
32bb98e8c1 |
||
|
9c6b8f1af5 |
||
|
7cc78d448d |
||
|
599c54c9e1 |
||
|
9be3bbbf0f |
||
|
97af214ac1 |
||
|
71bc063155 |
||
|
9a152932ee |
||
|
a8539b2927 |
||
|
0db9548995 |
||
|
e263f0b8a3 |
||
|
5ab9315f16 |
||
|
b6ff45138f |
||
|
b0a55df862 |
||
|
c51ee673e8 |
||
|
0959f28e95 |
||
|
1e02c26a9a |
||
|
548264d51a |
||
|
460a463be1 |
||
|
2f6869ed2a |
||
|
3c996f01a9 |
||
|
d9343730da |
||
|
7386ca1c42 |
||
|
2a0e07aca5 |
||
|
1e58847bcd |
||
|
b0ba73cf46 |
||
|
6181aa178d |
||
|
680c8f1ee3 |
||
|
e8af221e99 |
||
|
808ca348e8 |
||
|
f6dd38ad12 |
||
|
4064ac083e |
||
|
31f6b02149 |
||
|
8cd5b0b775 |
||
|
d426c3e12e |
||
|
8eed162367 |
||
|
fe1d850e1c |
||
|
bcb9a9d6c6 |
||
|
5a9ace0b19 |
||
|
185a5cb0ea |
||
|
44419fb0bf |
||
|
c00dfca2f0 |
||
|
023fde7a84 |
||
|
7c030ef362 |
||
|
8664b95ca1 |
||
|
74d8bc15ca |
||
|
cbe33e93c9 |
||
|
8cf12a2ab5 |
||
|
497ceb8cbb |
||
|
18a5962026 |
||
|
d8150813f1 |
||
|
4398ece2da |
||
|
8159bf456c |
||
|
56780677fb |
||
|
dbe0018e24 |
||
|
483245506b |
||
|
c61f854318 |
||
|
e646c0e6eb |
||
|
d596b86c8d |
||
|
3d378eb583 |
||
|
0a5edebea3 |
||
|
d736fab432 |
||
|
ff81b08491 |
||
|
d00916331d |
||
|
ec08c887f4 |
||
|
d7aa1ced53 |
||
|
8a1cd2841b |
||
|
5f8fbe1230 |
||
|
8cfe60b485 |
||
|
6156edf5fe |
||
|
e2326cd06a |
||
|
94eb5cdef3 |
||
|
8c552d3086 |
||
|
5a8797a6dd |
||
|
192dd64dbe |
||
|
259a96995a |
||
|
60da559332 |
||
|
79cf61c53e |
||
|
b31d75aeee |
||
|
6b835226cd |
||
|
7f132439be |
||
|
6365327595 |
||
|
8d40224d61 |
||
|
b3091d7366 |
||
|
fb21815ca0 |
||
|
0f39171f93 |
||
|
100fdd1df1 |
||
|
1cdb55a6a9 |
||
|
977eec8a66 |
||
|
05739ea959 |
||
|
ed735e5fa3 |
||
|
76cc56a215 |
||
|
e79da07973 |
||
|
3af08f0d07 |
||
|
a5ccc9a1a3 |
||
|
8a4cb13b18 |
||
|
c2f47a52b4 |
||
|
d26ce224ce |
||
|
58aab24c28 |
||
|
76649be6e4 |
||
|
af1aed0b6b |
||
|
baa0e08c2a |
||
|
d889e810f4 |
||
|
21f5fee403 |
||
|
fbb016a7fd |
||
|
cc0c4b638d |
||
|
c73450c0db |
||
|
5bb9dbd0f6 |
||
|
20be945e8c |
||
|
b11b7e62e9 |
||
|
76a3b3d2a5 |
||
|
65452f08ce |
||
|
75e492556b |
||
|
a58a4ed632 |
||
|
2ea85f31e7 |
||
|
38cc3bc7f2 |
||
|
04d6bfbd78 |
||
|
d7f6a1b186 |
||
|
b1e2a30dc1 |
||
|
defbbbff92 |
||
|
7c8cea74e4 |
||
|
a3ad40122d |
||
|
b74a1a3c32 |
||
|
894eaf0365 |
||
|
ed53d66aa7 |
||
|
c53566225c |
||
|
a1b6c35bd2 |
||
|
1977ca7db2 |
||
|
4b97382b7c |
||
|
5975b59c93 |
||
|
571bbb2db1 |
||
|
08bc365a7c |
||
|
e6829310ad |
||
|
a9949a07da |
||
|
5faeafedd5 |
||
|
0f872ab440 |
||
|
15afbe3001 |
||
|
a8be53e0dc |
||
|
90cf863305 |
||
|
5ace9e163d |
||
|
54af528f6c |
||
|
0a6b12329c |
||
|
41b796e007 |
||
|
925efe0db7 |
||
|
806c8814b6 |
||
|
70256dd0b9 |
||
|
ac520d656b |
||
|
3c40f83738 |
||
|
b2777e30f2 |
||
|
84fb1a2dc2 |
||
|
b73f8cc30e |
||
|
c3572f3212 |
||
|
85dda97e52 |
||
|
8114079eac |
||
|
444b138e97 |
||
|
24f05a03c0 |
||
|
b83e9e3257 |
||
|
2a83502ca1 |
||
|
0bf1329d03 |
||
|
c1f433ab60 |
||
|
23c881f4d1 |
||
|
e85834706a |
||
|
8327c48111 |
||
|
e7b96ed9ae |
||
|
3ab0c8fe22 |
||
|
4ae1f6cef9 |
||
|
3631e723bc |
||
|
ddcf350353 |
||
|
2b339f60bc |
||
|
52e399144a |
||
|
c45ded5a98 |
||
|
85eaed57e0 |
||
|
6e6de11aaa |
||
|
9f1b7de623 |
||
|
aeb572f211 |
||
|
b598e9fc0c |
||
|
b6cb946ae2 |
||
|
39afabf1b7 |
||
|
8cbf93fda4 |
||
|
03938ebf2c |
||
|
167a914e20 |
||
|
7f15c00f7e |
||
|
22070ea549 |
||
|
746d44922e |
||
|
ec99a2f3d9 |
||
|
d482177342 |
||
|
285fa3a0f5 |
||
|
98433a7c54 |
||
|
d4def8e5ae |
||
|
ed103bc5de |
||
|
15fb052716 |
||
|
ebbd66d056 |
||
|
a2c589fca4 |
||
|
1bd5168156 |
||
|
0576920b89 |
||
|
ca3c2b09cf |
||
|
695a00350b |
||
|
b7ca9c3502 |
||
|
fffd936b0f |
||
|
bd83c8d55c |
||
|
b905e0139c |
||
|
b9b844dd38 |
||
|
e9d370a4b4 |
||
|
248fb6724a |
||
|
bb01141cfc |
||
|
775375b8b4 |
||
|
010377870d |
||
|
9967786997 |
||
|
07eba40096 |
||
|
399fd6ff91 |
||
|
f1b95f5837 |
||
|
abc01bb64d |
||
|
bdc907ee67 |
||
|
1e99d51fe2 |
||
|
27b72663d4 |
||
|
bff4a3f6ba |
||
|
ba3275fed7 |
||
|
df58950585 |
||
|
fbf22b5ea2 |
||
|
1af3c43320 |
||
|
228101fd48 |
||
|
54a878f7b1 |
||
|
9df184d7f4 |
||
|
86dd3f0db7 |
||
|
89d9fcac66 |
||
|
f6214c9a98 |
||
|
988c6750aa |
||
|
04b745b584 |
||
|
f068bcca01 |
||
|
b3aeaafd00 |
||
|
d17015de44 |
||
|
93b6370759 |
||
|
55812ce2f0 |
||
|
467ae352f5 |
||
|
a1fe0b04d7 |
||
|
d510e0f600 |
||
|
67baf9a260 |
||
|
238afb4794 |
||
|
aa0c58ef0e |
||
|
54c9214b03 |
||
|
942e09e59e |
||
|
f08687b960 |
||
|
1f7ddd4d01 |
||
|
df54df5e65 |
||
|
1371cfa889 |
||
|
dd59a79005 |
||
|
fa52d99691 |
||
|
c54c811eec |
||
|
d5afac4104 |
||
|
3e8ef3c1b4 |
||
|
addf9ea324 |
||
|
ab24116b1b |
||
|
d4002b2e2b |
||
|
b0950abdd9 |
||
|
a9860fe62a |
||
|
dd389b0f94 |
||
|
1ce292a0b6 |
||
|
a2fac2b11c |
||
|
4862aee9a1 |
||
|
aa6592eec7 |
||
|
e996c4b375 |
||
|
73b94782f9 |
||
|
b737ab96b4 |
||
|
5c06accca0 |
||
|
3cd98457c2 |
||
|
944a231e5b |
||
|
5d84587ee2 |
||
|
9a4ca69590 |
||
|
9c8e8b4351 |
||
|
87389320fb |
||
|
eb1d724417 |
||
|
50d90d16bf |
||
|
35aca11feb |
||
|
84a14c0f25 |
||
|
bd9fe54fd7 |
||
|
74cd494162 |
||
|
297620f4a9 |
||
|
d657a609ff |
||
|
3c48ea49f3 |
||
|
ec363b2ef3 |
||
|
6d84d1b4c9 |
||
|
cfd1bf445f |
||
|
b4600c8306 |
||
|
636806ae17 |
||
|
40dc6e08ef |
||
|
ee43f001e4 |
||
|
3eef2b7f21 |
||
|
51d701521a |
||
|
b73aa888c2 |
||
|
6d092b4984 |
||
|
32ef43fe6e |
||
|
088bbd3bb6 |
||
|
e970a260a1 |
||
|
17800df0aa |
||
|
31aa9965d1 |
||
|
97ef7c4981 |
||
|
922ab07ff1 |
||
|
8170244812 |
||
|
9a9c98d0a0 |
||
|
44e65952f9 |
||
|
9c3d700584 |
||
|
61960aa744 |
||
|
5523d4ba88 |
||
|
2d9e109f97 |
||
|
8e6f8d7735 |
||
|
c3d09d34f8 |
||
|
cba746920a |
||
|
e33767de9b |
||
|
636d2fe43e |
||
|
fe388e903d |
||
|
b9f04ac71a |
||
|
96025c839d |
||
|
2145f1e272 |
||
|
f2a664953c |
4
.github/codeql-config.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
name: CodeQL Config
|
||||
|
||||
paths-ignore:
|
||||
- lib
|
38
.github/workflows/codeql.yml
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [nightly]
|
||||
pull_request:
|
||||
branches: [nightly]
|
||||
schedule:
|
||||
- cron: '05 10 * * 1'
|
||||
|
||||
jobs:
|
||||
codeql-analysis:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript', 'python']
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
config-file: ./.github/codeql-config.yml
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
4
.github/workflows/issues-stale.yml
vendored
|
@ -10,7 +10,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@v4
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: >
|
||||
This issue is stale because it has been open for 30 days with no activity.
|
||||
|
@ -30,7 +30,7 @@ jobs:
|
|||
days-before-close: 5
|
||||
|
||||
- name: Invalid Template
|
||||
uses: actions/stale@v4
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: >
|
||||
Invalid issues template.
|
||||
|
|
2
.github/workflows/issues.yml
vendored
|
@ -10,6 +10,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label Issues
|
||||
uses: dessant/label-actions@v2
|
||||
uses: dessant/label-actions@v4
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
|
|
59
.github/workflows/publish-docker.yml
vendored
|
@ -1,6 +1,7 @@
|
|||
name: Publish Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch: ~
|
||||
push:
|
||||
branches: [master, beta, nightly]
|
||||
tags: [v*]
|
||||
|
@ -12,41 +13,40 @@ jobs:
|
|||
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo ::set-output name=tag::${GITHUB_REF#refs/tags/}
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
elif [[ $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=tag::latest
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=tag::${GITHUB_REF#refs/heads/}
|
||||
echo "tag=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ $GITHUB_REF == refs/tags/*-beta ]]; then
|
||||
echo ::set-output name=branch::beta
|
||||
echo "branch=beta" >> $GITHUB_OUTPUT
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo ::set-output name=branch::master
|
||||
echo "branch=master" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=branch::${GITHUB_REF#refs/heads/}
|
||||
echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo ::set-output name=commit::${GITHUB_SHA}
|
||||
echo ::set-output name=build_date::$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
echo ::set-output name=docker_platforms::linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
|
||||
echo ::set-output name=docker_image::${{ secrets.DOCKER_REPO }}/tautulli
|
||||
echo "commit=${GITHUB_SHA}" >> $GITHUB_OUTPUT
|
||||
echo "docker_platforms=linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6" >> $GITHUB_OUTPUT
|
||||
echo "docker_image=${{ secrets.DOCKER_REPO }}/tautulli" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Cache Docker Layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
|
@ -54,22 +54,28 @@ jobs:
|
|||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
if: success()
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
if: success()
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract Docker Metadata
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.prepare.outputs.docker_image }}
|
||||
|
||||
- name: Docker Build and Push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
if: success()
|
||||
with:
|
||||
context: .
|
||||
|
@ -80,10 +86,10 @@ jobs:
|
|||
TAG=${{ steps.prepare.outputs.tag }}
|
||||
BRANCH=${{ steps.prepare.outputs.branch }}
|
||||
COMMIT=${{ steps.prepare.outputs.commit }}
|
||||
BUILD_DATE=${{ steps.prepare.outputs.build_date }}
|
||||
tags: |
|
||||
${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
|
||||
ghcr.io/${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.tag }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
|
@ -93,23 +99,10 @@ jobs:
|
|||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.2
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
status: ${{ needs.build-docker.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
|
|
133
.github/workflows/publish-installers.yml
vendored
|
@ -1,14 +1,18 @@
|
|||
name: Publish Installers
|
||||
|
||||
on:
|
||||
workflow_dispatch: ~
|
||||
push:
|
||||
branches: [master, beta, nightly]
|
||||
tags: [v*]
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
name: Build ${{ matrix.os_upper }} Installer
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
runs-on: ${{ matrix.os }}-${{ matrix.os_version }}
|
||||
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
@ -16,14 +20,18 @@ jobs:
|
|||
include:
|
||||
- os: 'windows'
|
||||
os_upper: 'Windows'
|
||||
os_version: 'latest'
|
||||
arch: 'x64'
|
||||
ext: 'exe'
|
||||
- os: 'macos'
|
||||
os_upper: 'MacOS'
|
||||
os_version: '14'
|
||||
arch: 'universal'
|
||||
ext: 'pkg'
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set Release Version
|
||||
id: get_version
|
||||
|
@ -32,14 +40,14 @@ jobs:
|
|||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
VERSION_NSIS=${GITHUB_REF#refs/tags/v}.1
|
||||
echo ::set-output name=VERSION_NSIS::${VERSION_NSIS/%-beta.1/.0}
|
||||
echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/v}
|
||||
echo ::set-output name=RELEASE_VERSION::${GITHUB_REF#refs/tags/}
|
||||
echo "VERSION_NSIS=${VERSION_NSIS/%-beta.1/.0}" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "VERSION=0.0.0" >> $GITHUB_ENV
|
||||
echo ::set-output name=VERSION_NSIS::0.0.0.0
|
||||
echo ::set-output name=VERSION::0.0.0
|
||||
echo ::set-output name=RELEASE_VERSION::${GITHUB_SHA::7}
|
||||
echo "VERSION_NSIS=0.0.0.0" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=0.0.0" >> $GITHUB_OUTPUT
|
||||
echo "RELEASE_VERSION=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [[ $GITHUB_REF == refs/tags/*-beta ]]; then
|
||||
echo "beta" > branch.txt
|
||||
|
@ -51,34 +59,29 @@ jobs:
|
|||
echo $GITHUB_SHA > version.txt
|
||||
|
||||
- name: Set Up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Cache Dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('package/requirements-package.txt') }}
|
||||
restore-keys: ${{ runner.os }}-pip-
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: '**/requirements*.txt'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r package/requirements-package.txt
|
||||
pip install -r package/requirements-package.txt --no-binary cffi
|
||||
|
||||
- name: Build Package
|
||||
run: |
|
||||
pyinstaller -y ./package/Tautulli-${{ matrix.os }}.spec
|
||||
|
||||
- name: Create Windows Installer
|
||||
uses: joncloud/makensis-action@v3.6
|
||||
uses: joncloud/makensis-action@v4.1
|
||||
if: matrix.os == 'windows'
|
||||
with:
|
||||
script-file: ./package/Tautulli.nsi
|
||||
arguments: >
|
||||
/DVERSION=${{ steps.get_version.outputs.VERSION_NSIS }}
|
||||
/DINSTALLER_NAME=..\Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
|
||||
/DINSTALLER_NAME=..\Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
|
||||
additional-plugin-paths: package/nsis-plugins
|
||||
|
||||
- name: Create MacOS Installer
|
||||
|
@ -89,13 +92,31 @@ jobs:
|
|||
--version ${{ steps.get_version.outputs.VERSION }} \
|
||||
--component ./dist/Tautulli.app \
|
||||
--scripts ./package/macos-scripts \
|
||||
Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
|
||||
Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Tautulli-${{ matrix.os }}-installer
|
||||
path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.${{ matrix.ext }}
|
||||
path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }}
|
||||
|
||||
virus-total:
|
||||
name: VirusTotal Scan
|
||||
needs: build-installer
|
||||
if: needs.build-installer.result == 'success' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Installers
|
||||
if: needs.build-installer.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Upload to VirusTotal
|
||||
uses: crazy-max/ghaction-virustotal@v4
|
||||
with:
|
||||
vt_api_key: ${{ secrets.VT_API_KEY }}
|
||||
files: |
|
||||
Tautulli-windows-installer/Tautulli-windows-*-x64.exe
|
||||
Tautulli-macos-installer/Tautulli-macos-*-universal.pkg
|
||||
|
||||
release:
|
||||
name: Release Installers
|
||||
|
@ -103,63 +124,44 @@ jobs:
|
|||
if: always() && startsWith(github.ref, 'refs/tags/') && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.2
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set Release Version
|
||||
id: get_version
|
||||
run: |
|
||||
echo ::set-output name=RELEASE_VERSION::${GITHUB_REF#refs/tags/}
|
||||
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Installers
|
||||
if: env.WORKFLOW_CONCLUSION == 'success'
|
||||
uses: actions/download-artifact@v2
|
||||
if: needs.build-installer.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Get Changelog
|
||||
id: get_changelog
|
||||
run: |
|
||||
echo ::set-output name=CHANGELOG::"$( sed -n '/^## /{p; :loop n; p; /^## /q; b loop}' CHANGELOG.md \
|
||||
| sed '$d' | sed '$d' | sed '$d' | sed ':a;N;$!ba;s/\n/%0A/g' )"
|
||||
CHANGELOG="$( sed -n '/^## /{p; :loop n; p; /^## /q; b loop}' CHANGELOG.md \
|
||||
| sed '$d' | sed '$d' | sed '$d' )"
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "CHANGELOG<<$EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "$EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
id: create_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GHACTIONS_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.RELEASE_VERSION }}
|
||||
release_name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
|
||||
name: Tautulli ${{ steps.get_version.outputs.RELEASE_VERSION }}
|
||||
body: |
|
||||
## Changelog
|
||||
|
||||
##${{ steps.get_changelog.outputs.CHANGELOG }}
|
||||
draft: false
|
||||
prerelease: ${{ endsWith(steps.get_version.outputs.RELEASE_VERSION, '-beta') }}
|
||||
|
||||
- name: Upload Windows Installer
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: env.WORKFLOW_CONCLUSION == 'success'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
|
||||
asset_name: Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
|
||||
asset_content_type: application/vnd.microsoft.portable-executable
|
||||
|
||||
- name: Upload MacOS Installer
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: env.WORKFLOW_CONCLUSION == 'success'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
|
||||
asset_name: Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.pkg
|
||||
asset_content_type: application/vnd.apple.installer+xml
|
||||
files: |
|
||||
Tautulli-windows-installer/Tautulli-windows-${{ steps.get_version.outputs.RELEASE_VERSION }}-x64.exe
|
||||
Tautulli-macos-installer/Tautulli-macos-${{ steps.get_version.outputs.RELEASE_VERSION }}-universal.pkg
|
||||
|
||||
discord:
|
||||
name: Discord Notification
|
||||
|
@ -167,23 +169,10 @@ jobs:
|
|||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.2
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
status: ${{ needs.build-installer.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
|
|
40
.github/workflows/publish-snap.yml
vendored
|
@ -1,6 +1,7 @@
|
|||
name: Publish Snap
|
||||
|
||||
on:
|
||||
workflow_dispatch: ~
|
||||
push:
|
||||
branches: [master, beta, nightly]
|
||||
tags: [v*]
|
||||
|
@ -14,55 +15,51 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- i386
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
- ppc64el
|
||||
#- s390x # broken at the moment
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/*-beta || $GITHUB_REF == refs/heads/beta ]]; then
|
||||
echo ::set-output name=RELEASE::beta
|
||||
echo "RELEASE=beta" >> $GITHUB_OUTPUT
|
||||
elif [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@master
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Tautulli-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@master
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/nightly'
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
|
@ -72,23 +69,10 @@ jobs:
|
|||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.2
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
status: ${{ needs.build-snap.result == 'success' && 'success' || contains(needs.*.result, 'failure') && 'failure' || 'cancelled' }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
|
|
5
.github/workflows/pull-requests.yml
vendored
|
@ -10,15 +10,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Comment on Pull Request
|
||||
uses: mshick/add-pr-comment@v1
|
||||
uses: mshick/add-pr-comment@v2
|
||||
if: github.base_ref != 'nightly'
|
||||
with:
|
||||
message: Pull requests must be made to the `nightly` branch. Thanks.
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repo-token-user-login: 'github-actions[bot]'
|
||||
|
||||
- name: Fail Workflow
|
||||
if: github.base_ref != 'nightly'
|
||||
|
|
44
.github/workflows/submit-winget.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: Submit winget
|
||||
|
||||
on:
|
||||
workflow_dispatch: ~
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
winget:
|
||||
name: Submit Winget Package
|
||||
runs-on: windows-latest
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Sync Winget Fork
|
||||
run: gh repo sync ${{ secrets.WINGET_USERNAME }}/winget-pkgs -b master
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
- name: Submit package to Windows Package Manager Community Repository
|
||||
run: |
|
||||
$wingetPackage = "Tautulli.Tautulli"
|
||||
$gitToken = "${{ secrets.WINGET_TOKEN }}"
|
||||
|
||||
$github = Invoke-RestMethod -uri "https://api.github.com/repos/Tautulli/Tautulli/releases/latest"
|
||||
$installerUrl = $github | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match "Tautulli-windows-.*-x64.exe" | Select -ExpandProperty browser_download_url
|
||||
$version = "$($github.tag_name.Trim('v')).1"
|
||||
|
||||
# getting latest wingetcreate file
|
||||
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
.\wingetcreate.exe update $wingetPackage -s -v $version -u $installerUrl -t $gitToken
|
||||
|
||||
virus-total:
|
||||
name: VirusTotal Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Upload to VirusTotal
|
||||
uses: crazy-max/ghaction-virustotal@v4
|
||||
with:
|
||||
vt_api_key: ${{ secrets.VT_API_KEY }}
|
||||
github_token: ${{ secrets.GHACTIONS_TOKEN }}
|
||||
update_release_body: true
|
||||
files: |
|
||||
.exe$
|
||||
.pkg$
|
3
.gitignore
vendored
|
@ -53,6 +53,9 @@ Thumbs.db
|
|||
#Ignore files generated by PyCharm
|
||||
*.idea/*
|
||||
|
||||
#Ignore files generated by VSCode
|
||||
*.vscode/*
|
||||
|
||||
#Ignore files generated by vi
|
||||
*.swp
|
||||
|
||||
|
|
516
CHANGELOG.md
|
@ -1,9 +1,520 @@
|
|||
# Changelog
|
||||
|
||||
## v2.9.0-beta (2022-01-25)
|
||||
## v2.15.3 (2025-08-03)
|
||||
|
||||
* Exporter:
|
||||
* New: Added hearingImpaired for subtitles and visualImpaired for audio attributes to exporter fields.
|
||||
* Graphs:
|
||||
* Fix: Remove duplicate "Total" entry in graph tooltips. (Thanks @zdimension) (#2534)
|
||||
* UI:
|
||||
* Fix: Failing to retrieve collections / playlists with over 1000 items.
|
||||
* Fix: Scrollbar not showing on macosx and webkit browsers. (#2221)
|
||||
* Fix: Incorrect rounding of minutes in global stats play duration.
|
||||
* Fix: Disable browser autocomplete for notification agent and newsletter agent configurations. (#2557)
|
||||
* API:
|
||||
* New: Added ability to return svg files using pms_image_proxy API command.
|
||||
* Other:
|
||||
* New: Added ability to set config values using environment variables. (Thanks @komuw) (#2309, #2543)
|
||||
|
||||
|
||||
## v2.15.2 (2025-04-12)
|
||||
|
||||
* Activity:
|
||||
* New: Added link to library by clicking media type icon.
|
||||
* New: Added stream count to tab title on homepage. (#2517)
|
||||
* History:
|
||||
* Fix: Check stream watched status before stream stopped status. (#2506)
|
||||
* Notifications:
|
||||
* Fix: ntfy notifications failing to send if provider link is blank.
|
||||
* Fix: Check Pushover notification attachment is under 5MB limit. (#2396)
|
||||
* Fix: Track URLs redirecting to the correct media page. (#2513)
|
||||
* New: Added audio profile notification parameters.
|
||||
* New: Added PATCH method for Webhook notifications.
|
||||
* Graphs:
|
||||
* New: Added Total line to daily streams graph. (Thanks @zdimension) (#2497)
|
||||
* UI:
|
||||
* Fix: Do not redirect API requests to the login page. (#2490)
|
||||
* Change: Swap source and stream columns in stream info modal.
|
||||
* Other:
|
||||
* Fix: Various typos. (Thanks @luzpaz) (#2520)
|
||||
* Fix: CherryPy CORS response header not being set correctly. (#2279)
|
||||
|
||||
|
||||
## v2.15.1 (2025-01-11)
|
||||
|
||||
* Activity:
|
||||
* Fix: Detection of HDR transcodes. (Thanks @cdecker08) (#2412, #2466)
|
||||
* Newsletters:
|
||||
* Fix: Disable basic authentication for /newsletter and /image endpoints. (#2472)
|
||||
* Exporter:
|
||||
* New: Added logos to season and episode exports.
|
||||
* Other:
|
||||
* Fix: Docker container https health check.
|
||||
|
||||
|
||||
## v2.15.0 (2024-11-24)
|
||||
|
||||
* Notes:
|
||||
* Support for Python 3.8 has been dropped. The minimum Python version is now 3.9.
|
||||
* Notifications:
|
||||
* New: Allow Telegram blockquote and tg-emoji HTML tags. (Thanks @MythodeaLoL) (#2427)
|
||||
* New: Added Plex slug and Plex Watch URL notification parameters. (#2420)
|
||||
* Change: Update OneSignal API calls to use the new API endpoint for Tautulli Remote App notifications.
|
||||
* Newsletters:
|
||||
* Fix: Dumping custom dates in raw newsletter json.
|
||||
* History:
|
||||
* Fix: Unable to fix match for artists. (#2429)
|
||||
* Exporter:
|
||||
* New: Added movie and episode hasVoiceActivity attribute to exporter fields.
|
||||
* New: Added subtitle canAutoSync attribute to exporter fields.
|
||||
* New: Added logos to the exporter fields.
|
||||
* UI:
|
||||
* New: Add friendly name to the top bar of config modals. (Thanks @peagravel) (#2432)
|
||||
* API:
|
||||
* New: Added plex slugs to metadata in the get_metadata API command.
|
||||
* Other:
|
||||
* Fix: Tautulli failing to start with Python 3.13. (#2426)
|
||||
|
||||
|
||||
## v2.14.6 (2024-10-12)
|
||||
|
||||
* Newsletters:
|
||||
* Fix: Allow formatting newsletter date parameters.
|
||||
* Change: Support apscheduler compatible cron expressions.
|
||||
* UI:
|
||||
* Fix: Round runtime before converting to human duration.
|
||||
* Fix: Make recently added/watched rows touch scrollable.
|
||||
* Other:
|
||||
* Fix: Auto-updater not running.
|
||||
|
||||
|
||||
## v2.14.5 (2024-09-20)
|
||||
|
||||
* Activity:
|
||||
* Fix: Display of 2k resolution on activity card.
|
||||
* Notifications:
|
||||
* Fix: ntfy notifications with special characters failing to send.
|
||||
* Other:
|
||||
* Fix: Memory leak with database closing. (#2404)
|
||||
|
||||
|
||||
## v2.14.4 (2024-08-10)
|
||||
|
||||
* Notifications:
|
||||
* Fix: Update Slack notification info card.
|
||||
* New: Added ntfy notification agent. (Thanks @nwithan8) (#2356, #2000)
|
||||
* UI:
|
||||
* Fix: macOS platform capitalization.
|
||||
* Other:
|
||||
* Fix: Remove deprecated getdefaultlocale. (Thanks @teodorstelian) (#2364, #2345)
|
||||
|
||||
|
||||
## v2.14.3 (2024-06-19)
|
||||
|
||||
* Graphs:
|
||||
* Fix: History table not loading when clicking on the graphs in some instances.
|
||||
* UI:
|
||||
* Fix: Scheduled tasks table not loading when certain tasks are disabled.
|
||||
* Removed: Unnecessary Remote Server checkbox from the settings page.
|
||||
* Other:
|
||||
* Fix: Webserver not restarting after the setup wizard.
|
||||
* Fix: Workaround webserver crashing in some instances.
|
||||
|
||||
|
||||
## v2.14.2 (2024-05-18)
|
||||
|
||||
* History:
|
||||
* Fix: Live TV activity not logging to history.
|
||||
* Fix: Incorrect grouping of live TV history.
|
||||
* Notifications:
|
||||
* Fix: Pushover configuration settings refreshing after entering a token.
|
||||
* Fix: Plex remote access down notifications not triggering.
|
||||
* Fix: Deleting all images from Cloudinary only deleting 1000 images.
|
||||
* New: Added platform version and product version notification parameters. (#2244)
|
||||
* New: Added LAN streams and WAN streams notification parameters. (#2276)
|
||||
* New: Added Dolby Vision notification parameters. (#2240)
|
||||
* New: Added live TV channel notification parameters.
|
||||
* Change: Improved Tautulli Remote App notification encryption method.
|
||||
* Note: Requires Tautulli Remote App version 3.2.4.
|
||||
* Exporter:
|
||||
* New: Added slug attribute to exporter fields.
|
||||
* New: Added track genres to exporter fields.
|
||||
* New: Added playlist source URI to exporter fields.
|
||||
* New: Added artProvider and thumbProvider to exporter fields.
|
||||
* UI:
|
||||
* Fix: Mask deleted usernames in the logs.
|
||||
* Fix: Live TV watch stats not showing on the media info page.
|
||||
* Fix: Users without access to Plex server not showing as inactive.
|
||||
* Removed: Deprecated synced item pages.
|
||||
* Removed: Anonymous redirect settings. Links now use browser no-referrer policy instead.
|
||||
* API:
|
||||
* New: Added Dolby Vision info to the get_metadata API command.
|
||||
* New: Added before and after parameters to the get_home_stats API command. (#2231)
|
||||
* Packages:
|
||||
* New: Universal binary for macOS for Apple silicon.
|
||||
* New: Bump Snap package to core22.
|
||||
* Other:
|
||||
* Change: Login cookie expires changed to max-age.
|
||||
* Change: Improved key generation for login password. It is recommended to reenter your HTTP Password in the settings after upgrading.
|
||||
* Removed: Python 2 compatibility code. (#2098, #2226) (Thanks @zdimension)
|
||||
|
||||
|
||||
## v2.13.4 (2023-12-07)
|
||||
|
||||
* UI:
|
||||
* Fix: Tautulli configuration settings page not loading when system language is None.
|
||||
* Fix: Login cookie expiring too quickly.
|
||||
|
||||
|
||||
## v2.13.3 (2023-12-03)
|
||||
|
||||
* Notifications:
|
||||
* New: Added duration_time notification parameter.
|
||||
* New: Added file_size_bytes notification parameter.
|
||||
* New: Added time formats notification text modifiers.
|
||||
* New: Added support for thetvdb_url for movies.
|
||||
* UI:
|
||||
* Fix: Activity card overflowing due to screen scaling. (#2033)
|
||||
* Fix: Stream duration on activity card not being updated on track changes in some cases. (#2206)
|
||||
|
||||
|
||||
## v2.13.2 (2023-10-26)
|
||||
|
||||
* History:
|
||||
* New: Added quarter values icons for history watch status. (#2179, #2156) (Thanks @herby2212)
|
||||
* Graphs:
|
||||
* New: Added concurrent streams per day graph. (#2046) (Thanks @herby2212)
|
||||
* Exporter:
|
||||
* New: Added metadata directory to exporter fields.
|
||||
* Removed: Banner exporter fields for tv shows.
|
||||
* UI:
|
||||
* New: Added last triggered time to notification agents and newsletter agent lists.
|
||||
* Other:
|
||||
* New: Added X-Plex-Language header override to config file.
|
||||
|
||||
|
||||
## v2.13.1 (2023-08-25)
|
||||
|
||||
* Notes:
|
||||
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
|
||||
* Other:
|
||||
* Fix: Tautulli failing to start on some systems.
|
||||
|
||||
|
||||
## v2.13.0 (2023-08-25)
|
||||
|
||||
* Notes:
|
||||
* Support for Python 3.7 has been dropped. The minimum Python version is now 3.8.
|
||||
* Notifications:
|
||||
* Fix: Improved watched notification trigger description. (#2104)
|
||||
* New: Added notification image option for iOS Tautulli Remote app.
|
||||
* Exporter:
|
||||
* New: Added track chapter export fields.
|
||||
* New: Added on-demand subtitle export fields.
|
||||
|
||||
|
||||
## v2.12.5 (2023-07-13)
|
||||
|
||||
* Activity:
|
||||
* New: Added d3d11va to list of hardware decoders.
|
||||
* History:
|
||||
* Fix: Incorrect grouping of play history.
|
||||
* New: Added button in settings to regroup play history.
|
||||
* Notifications:
|
||||
* Fix: Incorrect concurrent streams notifications by IP addresss for IPv6 addresses (#2096) (Thanks @pooley182)
|
||||
* UI:
|
||||
* Fix: Occasional UI crashing on Python 3.11.
|
||||
* New: Added multiselect user filters to History and Graphs pages. (#2090) (Thanks @zdimension)
|
||||
* API:
|
||||
* New: Added regroup_history API command.
|
||||
* Change: Updated graph API commands to accept a comma separated list of user IDs.
|
||||
|
||||
|
||||
## v2.12.4 (2023-05-23)
|
||||
|
||||
* History:
|
||||
* Fix: Set view offset equal to duration if a stream is stopped within the last 10 sec.
|
||||
* Other:
|
||||
* Fix: Database import may fail for some older databases.
|
||||
* Fix: Double-quoted strings for newer versions of SQLite. (#2015, #2057)
|
||||
* API:
|
||||
* Change: Return the ID for async API calls (export_metadata, notify, notify_newsletter).
|
||||
|
||||
|
||||
## v2.12.3 (2023-04-14)
|
||||
|
||||
* Activity:
|
||||
* Fix: Incorrect subtitle decision shown when subtitles are transcoded.
|
||||
* History:
|
||||
* Fix: Incorrect order when sorting by the duration column in the history tables.
|
||||
* Notifications:
|
||||
* Fix: Logging error when running scripts that use PlexAPI.
|
||||
* UI:
|
||||
* Fix: Calculate file sizes setting causing the media info table to fail to load.
|
||||
* Fix: Incorrect artwork and thumbnail shown for Live TV on the Most Active Libraries statistics card.
|
||||
* API:
|
||||
* Change: Renamed duration to play_duration in the get_history API response. (Note: duration kept for backwards compatibility.)
|
||||
|
||||
|
||||
## v2.12.2 (2023-03-16)
|
||||
|
||||
* Other:
|
||||
* Fix: Tautulli not starting on FreeBSD jails.
|
||||
|
||||
|
||||
## v2.12.1 (2023-03-14)
|
||||
|
||||
* Activity:
|
||||
* Fix: Stop checking for deprecated sync items sessions.
|
||||
* Change: Do not show audio language on activity cards for music.
|
||||
* Other:
|
||||
* Fix: Tautulli not starting on macOS.
|
||||
|
||||
|
||||
## v2.12.0 (2023-03-13)
|
||||
|
||||
* Notifications:
|
||||
* New: Added support for Telegram group topics. (#1980)
|
||||
* New: Added anidb_id and anidb_url notification parameters. (#1973)
|
||||
* New: Added notification triggers for Intro Marker, Commercial Marker, and Credits Marker.
|
||||
* New: Added various intro, commercial, and credits marker notification parameters.
|
||||
* New: Allow setting a custom Pushover notification sound. (#2005)
|
||||
* Change: Notification images are now uploaded directly to Discord without the need for a 3rd party image hosting service.
|
||||
* Change: Automatically strip whitespace from notification condition values.
|
||||
* Change: Trigger watched notifications based on the video watched completion behaviour setting.
|
||||
* Exporter:
|
||||
* Fix: Unable to run exporter when using the Snap package. (#2007)
|
||||
* New: Added credits marker, and audio/subtitle settings to export fields.
|
||||
* UI:
|
||||
* Fix: Incorrect styling and missing content for collection media info pages.
|
||||
* New: Added edition details field on movie media info pages. (#1957) (Thanks @herby2212)
|
||||
* New: Added setting to change the video watched completion behaviour.
|
||||
* New: Added watch time and user statistics to collection and playlist media info pages. (#1982, #2012) (Thanks @herby2212)
|
||||
* New: Added history table to collection and playlist media info pages.
|
||||
* New: Dynamically change watched status in the UI based on video watched completion behaviour setting.
|
||||
* New: Added hidden setting to override server name.
|
||||
* Change: Move track artist to a details field instead of in the title on track media info pages.
|
||||
* API:
|
||||
* New: Added section_id and user_id parameters to get_home_stats API command. (#1944)
|
||||
* New: Added marker info to get_metadata API command results.
|
||||
* New: Added media_type parameter to get_item_watch_time_stats and get_item_user_stats API commands. (#1982) (Thanks @herby2212)
|
||||
* New: Added last_refreshed timestamp to get_library_media_info API command response.
|
||||
* Other:
|
||||
* Change: Migrate analytics to Google Analytics 4.
|
||||
|
||||
|
||||
## v2.11.1 (2022-12-22)
|
||||
|
||||
* Activity:
|
||||
* Fix: Use source language instead of stream language on activity cards.
|
||||
* Notifications:
|
||||
* Fix: Blank start time notification parameters causing recently added notifications to fail. (#1940)
|
||||
* Other:
|
||||
* Fix: Tautulli failing to start when using python 3.7.
|
||||
* Fix: Snap install failing to start. (#1941)
|
||||
* Fix: Update check crashing when git is missing. (#1943) (Thanks @Minituff)
|
||||
|
||||
|
||||
## v2.11.0 (2022-12-22)
|
||||
|
||||
* Activity:
|
||||
* New: Added audio and subtitle language to activity cards. (#1831, #1900) (Thanks @fscorrupt)
|
||||
* History:
|
||||
* New: Log subtitle language and subtitle forced to database. (#1826)
|
||||
* Notifications:
|
||||
* Fix: Validating condition operators would fail with a blank parameter.
|
||||
* New: Added start time and stop time notification parameters. (#1931)
|
||||
* New: Added session_key to LunaSea notification payload. (#1929) (Thanks @JagandeepBrar)
|
||||
* Newsletters:
|
||||
* Fix: Allow CSS to support light and dark themes.
|
||||
* Exporter:
|
||||
* New: Added editionTitle to movie exporter fields.
|
||||
* Change: m3u8 export changed to .m3u file extension. File is still encoded using UTF-8.
|
||||
* UI:
|
||||
* Fix: Link watch statistics to media page using metadata from history. (#1882)
|
||||
* New: Show subtitle language and subtitle forced flag in stream data modal.
|
||||
* Other:
|
||||
* Fix: Mask more user and metadata fields for guest access. (#1913)
|
||||
* Change: Disable TLS 1.0 and 1.1 for the webserver. Minimum TLS version is 1.2. (#1870)
|
||||
* Change: Use system language for requests to Plex Media Server.
|
||||
|
||||
|
||||
## v2.10.5 (2022-11-07)
|
||||
|
||||
* Notifications:
|
||||
* New: Added edition_title notification parameter. (#1838)
|
||||
* Change: Track notifications link to MusicBrainz track instead of album.
|
||||
* Newsletters:
|
||||
* New: Added months time frame for newsletters. (#1876)
|
||||
* UI:
|
||||
* Fix: Broken link on library statistic cards. (#1852)
|
||||
* Fix: Check for IPv6 host when generating QR code for app registration.
|
||||
* Fix: Missing padding on condition operator dropdown on small screens.
|
||||
* Other:
|
||||
* Fix: Launching browser when webserver is bound to IPv6.
|
||||
* New: Tautulli can be installed via the Windows Package Manager (winget).
|
||||
* Change: Separate stdout and stderr console logging. (#1874)
|
||||
* API:
|
||||
* Fix: API not returning 400 response code.
|
||||
* New: Added edition_title to get_metadata API response.
|
||||
* New: Added collections to get_children_metadata API response.
|
||||
* New: Added user_thumb to get_history API response.
|
||||
* New: Validate custom notification conditions before saving notification agents. (#1846)
|
||||
* Change: Fallback to parent_thumb for seasons in get_metadata API response.
|
||||
|
||||
|
||||
## v2.10.4 (2022-09-05)
|
||||
|
||||
* Activity:
|
||||
* New: Added tooltip for quality profile on activity cards.
|
||||
* Notifications:
|
||||
* New: Added "does not begin with" and "does not end with" condition operators.
|
||||
* UI:
|
||||
* Fix: Album count showing 0 on library statistics.
|
||||
* Fix: Library statistics not showing up for libraries without any history.
|
||||
|
||||
|
||||
## v2.10.3 (2022-08-09)
|
||||
|
||||
* Notifications:
|
||||
* New: Added JSON support for MQTT notifications. (#1763)
|
||||
* New: Added show year notification parameter.
|
||||
* Exporter:
|
||||
* New: Added guids to artist, album, and track metadata export fields.
|
||||
* New: Added languageTag to stream media info export fields.
|
||||
* UI:
|
||||
* Fix: Long channel identifier overflowing activity card. (#1802)
|
||||
* Change: Use the last played item's artwork for library statistics cards.
|
||||
* Other:
|
||||
* Fix: Username log filter causing database to lock up. (#1705)
|
||||
* Change: Username log filter only applies to usernames longer than 3 characters. (#1806)
|
||||
* API:
|
||||
* New: Added parent_year and grandparent_year to get_metadata_details API command.
|
||||
* New: Added last played metadata to top_libraries and top_users in get_home_stats API command.
|
||||
* New: Allow fallback to another PMS image in pms_image_proxy API command.
|
||||
|
||||
|
||||
## v2.10.2 (2022-07-03)
|
||||
|
||||
* Activity:
|
||||
* Fix: Incorrect audio stream info shown on the activity card when playing a secondary audio track.
|
||||
* UI:
|
||||
* Fix: Usernames not showing on the home statistics cards.
|
||||
* Fix: Do not save a user's friendly name if it is the same as the username.
|
||||
* Change: Update library icons to the latest Plex style.
|
||||
|
||||
|
||||
## v2.10.1 (2022-06-01)
|
||||
|
||||
* Notifications:
|
||||
* New: Added support for MusicBrainz (mbid://) guids in notification parameters without MusicBrainz lookup enabled. Requires Plex Media Server 1.27.0 or newer with refreshed Plex Music agent metadata.
|
||||
* Mobile App:
|
||||
* Fix: OneSignal validation failing when registering a device.
|
||||
* API:
|
||||
* New: Added grandparent_guids and parent_guids to get_metadata API command.
|
||||
* Change: Updated continent in get_geoip_lookup API command.
|
||||
* Change: Removed server_token from from get_users API command.
|
||||
* Change: shared_libraries changed to a list instead of a string for get_users API command.
|
||||
|
||||
|
||||
## v2.10.0 (2022-05-23)
|
||||
|
||||
* Activity:
|
||||
* Fix: Detection of Dolby Vision missing for PMS 1.26.1.
|
||||
* Notifications:
|
||||
* Fix: Parsing of filename notification parameter incorrect for Windows PMS.
|
||||
* Exporter:
|
||||
* New: Added additional theme and label export fields.
|
||||
* UI:
|
||||
* Fix: Slow loading of collections and playlists tables.
|
||||
* Change: Update default user thumbnail image to match Plex Web.
|
||||
* API:
|
||||
* Change: Values for get_users_table and get_libraries_table return an integer instead of "Checked".
|
||||
|
||||
|
||||
## v2.9.7 (2022-04-11)
|
||||
|
||||
* UI:
|
||||
* Fix: Managed user missing the username in the Users table.
|
||||
|
||||
|
||||
## v2.9.6 (2022-04-10)
|
||||
|
||||
* Activity:
|
||||
* New: Improved display of dynamic range on the activity cards. (Thanks @herby2212)
|
||||
* Notifications:
|
||||
* Change: Make include summary option apply to all media types for Discord and Slack notifications.
|
||||
* UI:
|
||||
* Fix: Validating Plex login in the setup wizard. (#1697)
|
||||
* New: Added hidden username, email, and full name columns to users table.
|
||||
* Other:
|
||||
* Fix: Apply pms_timeout setting to websocket connection.
|
||||
* Fix: Importing of Plex username instead of the full name. (#1710)
|
||||
|
||||
|
||||
## v2.9.5 (2022-03-26)
|
||||
|
||||
* Note:
|
||||
* Updated Snap packages are currently unavailable due to an upstream issue.
|
||||
* Activity:
|
||||
* Change: Improve calculation for transcode progress bar percentage on the activity cards.
|
||||
* History:
|
||||
* Fix: Live TV history filter not working. (#1691)
|
||||
* Newsletter:
|
||||
* Fix: Newsletter not showing different album types. (#1559)
|
||||
* UI:
|
||||
* Fix: Display season summary on the media info page if available with a fallback to show summary. (#1657)
|
||||
* Change: Colour active filter buttons to improve contrast. (#1663)
|
||||
* API:
|
||||
* New: Added transcode offset keys to get_activity command.
|
||||
* Other:
|
||||
* Fix: Reschedule backup task after changing backup interval. (#1662)
|
||||
* Fix: Dynamic anonymous redirect setting not being enabled by default after the setup wizard.
|
||||
* Fix: Usernames with special characters not being filtered in the logs.
|
||||
|
||||
|
||||
## v2.9.4 (2022-02-12)
|
||||
|
||||
* UI:
|
||||
* Fix: Setup wizard appearing when restarting after saving settings.
|
||||
* Other:
|
||||
* Fix: Stop Tautulli from starting multiple instances on Windows after a clean reinstall. Check the startup items in Windows Task Manager if it is still occurring.
|
||||
|
||||
|
||||
## v2.9.3 (2022-02-09)
|
||||
|
||||
* UI:
|
||||
* Fix: Setup wizard looping.
|
||||
* Other:
|
||||
* Fix: Logger username masking preventing Tautulli from starting on new installs.
|
||||
|
||||
|
||||
## v2.9.2 (2022-02-08)
|
||||
|
||||
* Notification:
|
||||
* New: Added support for additional Telegram HTML tags.
|
||||
* Removed: Revert Telegram defaulting to MarkdownV2 and only support HTML. (#1635)
|
||||
* Other:
|
||||
* Fix: The Local user being masked in the logs.
|
||||
|
||||
|
||||
## v2.9.1 (2022-02-07)
|
||||
|
||||
* Other:
|
||||
* Fix: Incorrect changelog version number and date.
|
||||
|
||||
|
||||
## v2.9.0 (2022-02-07)
|
||||
|
||||
* Notification:
|
||||
* New: Added track disc number notification parameter.
|
||||
* Change: Default Telegram messages to MarkdownV2 when HTML is disabled. (#1635)
|
||||
* Exporter:
|
||||
* Fix: Images not being included in export zip file download.
|
||||
* UI:
|
||||
* Fix: Favicon missing from the newsletter authentication page.
|
||||
* Fix: IPv6 details not being shown in IP address modal. (#1629)
|
||||
* Fix: PWA not respecting device rotation settings. (#1633)
|
||||
* New: Added intermediary login page to the Plex XML shortcuts.
|
||||
* New: Added setting to mask usernames in logs (enabled by default).
|
||||
* New: Added location, secure connection, and Plex Relay details to IP address modal.
|
||||
|
@ -12,9 +523,10 @@
|
|||
* API:
|
||||
* New: Added get_tautulli_info API command.
|
||||
* New: Added location, secure, and relayed to get_history API response.
|
||||
* Change: Null pms_token and jwt_token in the response of the get_settings API command.
|
||||
* Change: Null pms_token and jwt_token in the response of the get_settings API command. (#1616)
|
||||
* Other:
|
||||
* Fix: Better validation of config when saving settings.
|
||||
* Fix: Correct section_id and prevent rating_key collisions when updating metadata. (#1640)
|
||||
* Change: Proxy Plex token check and Plex downloads json through the Tautulli server.
|
||||
* Change: Remove tokens from downloaded database and config files.
|
||||
* Change: Do not import pms_token or jwt_secret when importing a config file.
|
||||
|
|
|
@ -9,12 +9,12 @@ All pull requests should be based on the `nightly` branch, to minimize cross mer
|
|||
### Python Code
|
||||
|
||||
#### Compatibility
|
||||
The code should work with Python 3.6+. Note that Tautulli runs on many different platforms.
|
||||
The code should work with Python 3.8+. Note that Tautulli runs on many different platforms.
|
||||
|
||||
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `plexpy.logger.*` for this. Web requests are invoked via `plexpy.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.
|
||||
|
||||
#### Code conventions
|
||||
Although Tautulli did not adapt a code convention in the past, we try to follow the [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
|
||||
Although Tautulli did not adopt a code convention in the past, we try to follow [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
|
||||
|
||||
* 4 space indentation
|
||||
* 80 characters per line
|
||||
|
|
|
@ -25,4 +25,4 @@ CMD [ "python", "Tautulli.py", "--datadir", "/config" ]
|
|||
ENTRYPOINT [ "./start.sh" ]
|
||||
|
||||
EXPOSE 8181
|
||||
HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1
|
||||
HEALTHCHECK --start-period=90s CMD curl -ILfks https://localhost:8181/status > /dev/null || curl -ILfs http://localhost:8181/status > /dev/null || exit 1
|
||||
|
|
22
README.md
|
@ -36,7 +36,7 @@ and [PlexWatchWeb](https://github.com/ecleese/plexWatchWeb).
|
|||
[![Docker Stars][badge-docker-stars]][DockerHub]
|
||||
[![Downloads][badge-downloads]][Releases Latest]
|
||||
|
||||
[badge-python]: https://img.shields.io/badge/python->=3.6-blue?style=flat-square
|
||||
[badge-python]: https://img.shields.io/badge/python->=3.9-blue?style=flat-square
|
||||
[badge-docker-pulls]: https://img.shields.io/docker/pulls/tautulli/tautulli?style=flat-square
|
||||
[badge-docker-stars]: https://img.shields.io/docker/stars/tautulli/tautulli?style=flat-square
|
||||
[badge-downloads]: https://img.shields.io/github/downloads/Tautulli/Tautulli/total?style=flat-square
|
||||
|
@ -57,24 +57,24 @@ Read the [Installation Guides][Installation] for instructions on how to install
|
|||
[badge-release-nightly-last-commit]: https://img.shields.io/github/last-commit/Tautulli/Tautulli/nightly?style=flat-square&color=blue
|
||||
[badge-release-nightly-commits]: https://img.shields.io/github/commits-since/Tautulli/Tautulli/latest/nightly?style=flat-square&color=blue
|
||||
[badge-docker-master]: https://img.shields.io/badge/docker-latest-blue?style=flat-square
|
||||
[badge-docker-master-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/master?style=flat-square
|
||||
[badge-docker-master-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-docker.yml?style=flat-square&branch=master
|
||||
[badge-docker-beta]: https://img.shields.io/badge/docker-beta-blue?style=flat-square
|
||||
[badge-docker-beta-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/beta?style=flat-square
|
||||
[badge-docker-beta-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-docker.yml?style=flat-square&branch=beta
|
||||
[badge-docker-nightly]: https://img.shields.io/badge/docker-nightly-blue?style=flat-square
|
||||
[badge-docker-nightly-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/nightly?style=flat-square
|
||||
[badge-docker-nightly-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-docker.yml?style=flat-square&branch=nightly
|
||||
[badge-snap-master]: https://img.shields.io/badge/snap-stable-blue?style=flat-square
|
||||
[badge-snap-master-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Snap/master?style=flat-square
|
||||
[badge-snap-master-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-snap.yml?style=flat-square&branch=master
|
||||
[badge-snap-beta]: https://img.shields.io/badge/snap-beta-blue?style=flat-square
|
||||
[badge-snap-beta-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Snap/beta?style=flat-square
|
||||
[badge-snap-beta-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-snap.yml?style=flat-square&branch=beta
|
||||
[badge-snap-nightly]: https://img.shields.io/badge/snap-edge-blue?style=flat-square
|
||||
[badge-snap-nightly-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Snap/nightly?style=flat-square
|
||||
[badge-snap-nightly-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-snap.yml?style=flat-square&branch=nightly
|
||||
[badge-installer-master-win]: https://img.shields.io/github/v/release/Tautulli/Tautulli?label=windows&style=flat-square
|
||||
[badge-installer-master-macos]: https://img.shields.io/github/v/release/Tautulli/Tautulli?label=macos&style=flat-square
|
||||
[badge-installer-master-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Installers/master?style=flat-square
|
||||
[badge-installer-master-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-installers.yml?style=flat-square&branch=master
|
||||
[badge-installer-beta-win]: https://img.shields.io/github/v/release/Tautulli/Tautulli?label=windows&include_prereleases&style=flat-square
|
||||
[badge-installer-beta-macos]: https://img.shields.io/github/v/release/Tautulli/Tautulli?label=macos&include_prereleases&style=flat-square
|
||||
[badge-installer-beta-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Installers/beta?style=flat-square
|
||||
[badge-installer-nightly-ci]: https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Installers/nightly?style=flat-square
|
||||
[badge-installer-beta-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-installers.yml?style=flat-square&branch=beta
|
||||
[badge-installer-nightly-ci]: https://img.shields.io/github/actions/workflow/status/Tautulli/Tautulli/.github/workflows/publish-installers.yml?style=flat-square&branch=nightly
|
||||
|
||||
## Support
|
||||
|
||||
|
@ -129,7 +129,7 @@ This is free software under the GPL v3 open source license. Feel free to do with
|
|||
but any modification must be open sourced. A copy of the license is included.
|
||||
|
||||
This software includes Highsoft software libraries which you may freely distribute for
|
||||
non-commercial use. Commerical users must licence this software, for more information visit
|
||||
non-commercial use. Commercial users must licence this software, for more information visit
|
||||
https://shop.highsoft.com/faq/non-commercial#non-commercial-redistribution.
|
||||
|
||||
|
||||
|
|
42
Tautulli.py
|
@ -23,18 +23,18 @@ import sys
|
|||
# Ensure lib added to path, before any other imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib'))
|
||||
|
||||
from future.builtins import str
|
||||
|
||||
import appdirs
|
||||
import argparse
|
||||
import datetime
|
||||
import locale
|
||||
import platformdirs
|
||||
import pytz
|
||||
import signal
|
||||
import shutil
|
||||
import time
|
||||
import threading
|
||||
import tzlocal
|
||||
import ctypes
|
||||
|
||||
import plexpy
|
||||
from plexpy import common, config, database, helpers, logger, webstart
|
||||
|
@ -70,8 +70,26 @@ def main():
|
|||
plexpy.SYS_ENCODING = None
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
plexpy.SYS_LANGUAGE, plexpy.SYS_ENCODING = locale.getdefaultlocale()
|
||||
|
||||
# Attempt to get the system's locale settings
|
||||
language_code, encoding = locale.getlocale()
|
||||
|
||||
# Special handling for Windows platform
|
||||
if sys.platform == 'win32':
|
||||
# Get the user's current language settings on Windows
|
||||
windll = ctypes.windll.kernel32
|
||||
lang_id = windll.GetUserDefaultLCID()
|
||||
|
||||
# Map Windows language ID to locale identifier
|
||||
language_code = locale.windows_locale.get(lang_id, '')
|
||||
|
||||
# Get the preferred encoding
|
||||
encoding = locale.getpreferredencoding()
|
||||
|
||||
# Assign values to application-specific variable
|
||||
plexpy.SYS_LANGUAGE = language_code
|
||||
plexpy.SYS_ENCODING = encoding
|
||||
|
||||
except (locale.Error, IOError):
|
||||
pass
|
||||
|
||||
|
@ -111,7 +129,7 @@ def main():
|
|||
if args.quiet:
|
||||
plexpy.QUIET = True
|
||||
|
||||
# Do an intial setup of the logger.
|
||||
# Do an initial setup of the logger.
|
||||
# Require verbose for pre-initilization to see critical errors
|
||||
logger.initLogger(console=not plexpy.QUIET, log_dir=False, verbose=True)
|
||||
|
||||
|
@ -186,7 +204,7 @@ def main():
|
|||
if args.datadir:
|
||||
plexpy.DATA_DIR = args.datadir
|
||||
elif plexpy.FROZEN:
|
||||
plexpy.DATA_DIR = appdirs.user_data_dir("Tautulli", False)
|
||||
plexpy.DATA_DIR = platformdirs.user_data_dir("Tautulli", False)
|
||||
else:
|
||||
plexpy.DATA_DIR = plexpy.PROG_DIR
|
||||
|
||||
|
@ -246,23 +264,13 @@ def main():
|
|||
# Start the background threads
|
||||
plexpy.start()
|
||||
|
||||
# Force the http port if neccessary
|
||||
# Force the http port if necessary
|
||||
if args.port:
|
||||
plexpy.HTTP_PORT = args.port
|
||||
logger.info('Using forced web server port: %i', plexpy.HTTP_PORT)
|
||||
else:
|
||||
plexpy.HTTP_PORT = int(plexpy.CONFIG.HTTP_PORT)
|
||||
|
||||
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||
# and for CherryPy.
|
||||
if plexpy.CONFIG.ENABLE_HTTPS:
|
||||
try:
|
||||
import OpenSSL
|
||||
except ImportError:
|
||||
logger.warn("The pyOpenSSL module is missing. Install this "
|
||||
"module to enable HTTPS. HTTPS will be disabled.")
|
||||
plexpy.CONFIG.ENABLE_HTTPS = False
|
||||
|
||||
# Try to start the server. Will exit here is address is already in use.
|
||||
webstart.start()
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
|
||||
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
|
||||
|
@ -30,7 +31,7 @@
|
|||
|
||||
<!-- ICONS -->
|
||||
<!-- Android -->
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.9.0" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="#282a2d">
|
||||
<!-- Apple -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
|
||||
|
@ -123,11 +124,6 @@
|
|||
% else:
|
||||
<li><a href="graphs">Graphs</a></li>
|
||||
% endif
|
||||
% if title == "Synced Items":
|
||||
<li class="active"><a href="sync">Synced Items</a></li>
|
||||
% else:
|
||||
<li><a href="sync">Synced Items</a></li>
|
||||
% endif
|
||||
% if title == "Settings":
|
||||
<li class="dropdown active">
|
||||
% else:
|
||||
|
@ -236,8 +232,9 @@ ${next.modalIncludes()}
|
|||
<ul id="donation_type" class="nav nav-pills" role="tablist" style="display: flex; justify-content: center; margin: 10px 0;">
|
||||
<li class="active"><a href="#github-donation" role="tab" data-toggle="tab">GitHub</a></li>
|
||||
<li><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
|
||||
<li><a href="#stripe-donation" role="tab" data-toggle="tab">Stripe</a></li>
|
||||
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab">Crypto</a></li>
|
||||
<li><a href="#crypto-donation" role="tab" data-toggle="tab" id="crypto-donation-tab">Crypto</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="github-donation" style="text-align: center">
|
||||
|
@ -262,12 +259,23 @@ ${next.modalIncludes()}
|
|||
</p>
|
||||
<p class="small-muted">(Patreon has a fee)</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="stripe-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to Stripe.
|
||||
</p>
|
||||
<p>
|
||||
<a href="${anon_url('https://donate.stripe.com/5kA7vnb7dczVbxC9AA')}" target="_blank" rel="noreferrer">
|
||||
<img src="images/Stripe_wordmark_-_white_small_28px.png" alt="Stripe" style="background-color: #7068fe; border-radius: 3px; padding: 3px;">
|
||||
</a>
|
||||
</p>
|
||||
<p class="small-muted">(Stripe has a fee)</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="paypal-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to PayPal.
|
||||
</p>
|
||||
<p>
|
||||
<a href="${anon_url('https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=6XPPKTDSX9QFL&lc=US&item_name=Tautulli¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donate_LG%2egif%3aNonHosted')}" target="_blank" rel="noreferrer">
|
||||
<a href="${anon_url('https://www.paypal.com/donate/?hosted_button_id=CUHSQ99KAKC5Q')}" target="_blank" rel="noreferrer">
|
||||
<img src="images/gold-rect-paypal-34px.png" alt="PayPal">
|
||||
</a>
|
||||
</p>
|
||||
|
@ -275,7 +283,16 @@ ${next.modalIncludes()}
|
|||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="crypto-donation" style="text-align: center">
|
||||
<p>
|
||||
Click the button below to continue to Coinbase.
|
||||
Select a cryptocurrency.
|
||||
</p>
|
||||
<select class="form-control" id="crypto-select"></select>
|
||||
<div id="crypto-qrcode"></div>
|
||||
<div id="crypto-address" class="form-group">
|
||||
<label>Address:</label>
|
||||
<span class="inline-pre" id="crypto-address-value"></span>
|
||||
</div>
|
||||
<p>
|
||||
Or click the button below to continue to Coinbase.
|
||||
</p>
|
||||
<a href="${anon_url('https://commerce.coinbase.com/checkout/8a9fa08c-8a38-409e-9220-868124c4ba0c')}" target="_blank" rel="noreferrer" class="donate-with-crypto">
|
||||
<span>Donate with Crypto</span>
|
||||
|
@ -323,6 +340,7 @@ ${next.modalIncludes()}
|
|||
<script src="${http_root}js/blurhash_pure_js_port.min.js"></script>
|
||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||
<script src="${http_root}js/ajaxNotifications.js"></script>
|
||||
<script src="${http_root}js/kjua.min.js"></script>
|
||||
<script>
|
||||
% if _session['user_group'] == 'admin':
|
||||
$('body').on('click', '#updateDismiss', function() {
|
||||
|
@ -396,6 +414,42 @@ ${next.modalIncludes()}
|
|||
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
|
||||
});
|
||||
|
||||
$('#crypto-donation-tab').one('shown.bs.tab', function (e) {
|
||||
$.ajax({
|
||||
url: 'https://tautulli.com/donate/crypto-addresses.json',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
cache: false,
|
||||
async: true,
|
||||
success: function (data) {
|
||||
$('#crypto-select').empty().append('<option selected disabled>Select Cryptocurrency</option>');
|
||||
$.each(data, function (index, crypto) {
|
||||
$('<option/>', {
|
||||
text: crypto.name + ' (' + crypto.symbol + ')',
|
||||
value: crypto.address
|
||||
}).appendTo('#crypto-select');
|
||||
});
|
||||
},
|
||||
error: function () {
|
||||
$('#crypto-select').empty().append('<option selected disabled>Error: Unable to load addresses</option>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#crypto-select').change(function() {
|
||||
var address = $(this).val();
|
||||
$('#crypto-qrcode').empty().kjua({
|
||||
text: address,
|
||||
render: 'canvas',
|
||||
ecLevel: 'H',
|
||||
size: 256,
|
||||
fill: '#000',
|
||||
back: '#eee'
|
||||
}).show();
|
||||
$('#crypto-address-value').text(address);
|
||||
$('#crypto-address').show();
|
||||
})
|
||||
|
||||
% endif
|
||||
|
||||
$('.dropdown-toggle').click(function (e) {
|
||||
|
|
|
@ -11,6 +11,7 @@ DOCUMENTATION :: END
|
|||
|
||||
<%!
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import plexpy
|
||||
from plexpy import common, logger
|
||||
|
@ -71,10 +72,18 @@ DOCUMENTATION :: END
|
|||
<td>System Timezone:</td>
|
||||
<td>${str(plexpy.SYS_TIMEZONE)} (${'UTC{}'.format(plexpy.SYS_UTC_OFFSET)})
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System Language:</td>
|
||||
<td>${plexpy.SYS_LANGUAGE}${' (override {})'.format(plexpy.CONFIG.PMS_LANGUAGE) if plexpy.CONFIG.PMS_LANGUAGE else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Python Version:</td>
|
||||
<td>${sys.version}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SQLite Version:</td>
|
||||
<td>${sqlite3.sqlite_version}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="top-line">Resources:</td>
|
||||
<td class="top-line">
|
||||
|
|
6
data/interfaces/default/css/bootstrap-select.min.css
vendored
Normal file
|
@ -79,7 +79,6 @@ select.form-control {
|
|||
color: #eee !important;
|
||||
border: 0px solid #444 !important;
|
||||
background: #555 !important;
|
||||
padding: 1px 2px;
|
||||
transition: background-color .3s;
|
||||
}
|
||||
.selectize-control.form-control .selectize-input {
|
||||
|
@ -87,7 +86,6 @@ select.form-control {
|
|||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
.selectize-control.form-control.selectize-pms-ip .selectize-input {
|
||||
padding-left: 12px !important;
|
||||
|
@ -122,6 +120,16 @@ select.form-control {
|
|||
#condition-widget .fa-minus {
|
||||
cursor: pointer;
|
||||
}
|
||||
#condition-widget .condition-operator-col {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#condition-widget .condition-operator-col {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
.react-selectize.root-node .react-selectize-control .react-selectize-placeholder {
|
||||
color: #eee !important;
|
||||
}
|
||||
|
@ -330,20 +338,20 @@ object {
|
|||
}
|
||||
.btn-dark:focus,
|
||||
.btn-dark.focus {
|
||||
color: #d7d7d7;
|
||||
background-color: #3B3B3B;
|
||||
color: #d7d7d7;
|
||||
background-color: #3B3B3B;
|
||||
}
|
||||
.btn-dark:hover {
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
.btn-dark:active,
|
||||
.btn-dark.active,
|
||||
.open > .dropdown-toggle.btn-dark {
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
.btn-dark:active:hover,
|
||||
.btn-dark.active:hover,
|
||||
|
@ -354,13 +362,13 @@ object {
|
|||
.btn-dark:active.focus,
|
||||
.btn-dark.active.focus,
|
||||
.open > .dropdown-toggle.btn-dark.focus {
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
}
|
||||
.btn-dark:active,
|
||||
.btn-dark.active,
|
||||
.open > .dropdown-toggle.btn-dark {
|
||||
background-image: none;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-dark.disabled,
|
||||
.btn-dark[disabled],
|
||||
|
@ -380,8 +388,8 @@ fieldset[disabled] .btn-dark:active,
|
|||
.btn-dark.disabled.active,
|
||||
.btn-dark[disabled].active,
|
||||
fieldset[disabled] .btn-dark.active {
|
||||
background-color: #333;
|
||||
color: #aaa;
|
||||
background-color: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
.btn-dark.inactive:hover {
|
||||
color: #d7d7d7;
|
||||
|
@ -390,30 +398,30 @@ fieldset[disabled] .btn-dark.active {
|
|||
cursor: default;
|
||||
}
|
||||
.btn-dark .badge {
|
||||
color: #e5e5e5;
|
||||
background-color: #3B3B3B;
|
||||
color: #e5e5e5;
|
||||
background-color: #3B3B3B;
|
||||
}
|
||||
.btn-bright {
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
}
|
||||
.btn-bright:focus,
|
||||
.btn-bright.focus {
|
||||
color: #eee;
|
||||
background-color: #eb8600;
|
||||
color: #eee;
|
||||
background-color: #eb8600;
|
||||
}
|
||||
.btn-bright:hover {
|
||||
color: #eee;
|
||||
background-color: #e59029;
|
||||
box-shadow: inset 0 1px 0 #ebac60;
|
||||
color: #eee;
|
||||
background-color: #e59029;
|
||||
box-shadow: inset 0 1px 0 #ebac60;
|
||||
}
|
||||
.btn-bright:active,
|
||||
.btn-bright.active,
|
||||
.open > .dropdown-toggle.btn-bright {
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
}
|
||||
.btn-bright:active:hover,
|
||||
.btn-bright.active:hover,
|
||||
|
@ -424,14 +432,14 @@ fieldset[disabled] .btn-dark.active {
|
|||
.btn-bright:active.focus,
|
||||
.btn-bright.active.focus,
|
||||
.open > .dropdown-toggle.btn-bright.focus {
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
}
|
||||
.btn-bright:active,
|
||||
.btn-bright.active,
|
||||
.open > .dropdown-toggle.btn-bright {
|
||||
background-image: none;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-bright.disabled,
|
||||
.btn-bright[disabled],
|
||||
|
@ -451,13 +459,13 @@ fieldset[disabled] .btn-bright:active,
|
|||
.btn-bright.disabled.active,
|
||||
.btn-bright[disabled].active,
|
||||
fieldset[disabled] .btn-bright.active {
|
||||
background-color: #cc7b19;
|
||||
border-color: #b56d16;
|
||||
background-color: #cc7b19;
|
||||
border-color: #b56d16;
|
||||
}
|
||||
.btn-bright .badge {
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
box-shadow: inset 0 1px 0 #e7993b;
|
||||
}
|
||||
.btn-danger.btn-edit {
|
||||
color: #d7d7d7;
|
||||
|
@ -471,14 +479,14 @@ fieldset[disabled] .btn-bright.active {
|
|||
border-color: #ac2925;
|
||||
}
|
||||
.btn-danger.btn-edit.active {
|
||||
color: #eee;
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
color: #eee;
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
}
|
||||
.btn-danger.btn-edit.active:hover {
|
||||
color: #eee;
|
||||
background-color: #ac2925;
|
||||
border-color: #761c19;
|
||||
color: #eee;
|
||||
background-color: #ac2925;
|
||||
border-color: #761c19;
|
||||
}
|
||||
.btn-group select {
|
||||
margin-top: 0;
|
||||
|
@ -659,12 +667,12 @@ textarea.form-control:focus {
|
|||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
-ms-touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
background-image: none;
|
||||
background-color: #3B3B3B;
|
||||
color: #e5e5e5;
|
||||
|
@ -680,6 +688,13 @@ textarea.form-control:focus {
|
|||
.btn-form:focus {
|
||||
color: #eee;
|
||||
}
|
||||
.btn-filter.active,
|
||||
.btn-filter.active.focus {
|
||||
background-color: #b7800a !important;
|
||||
}
|
||||
.btn-filter.active:hover {
|
||||
background-color: #896007 !important;
|
||||
}
|
||||
.form-control-feedback {
|
||||
color: #E5A00D;
|
||||
margin: 5px 40px 5px 0;
|
||||
|
@ -950,7 +965,7 @@ a .users-poster-face:hover {
|
|||
font-size: 10px;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
line-height: 14px;
|
||||
line-height: 10px;
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
@ -990,6 +1005,12 @@ a .users-poster-face:hover {
|
|||
font-size: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
.dashboard-activity-info-channel {
|
||||
display: inline-block;
|
||||
max-width: 75px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard-activity-progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
|
@ -1260,7 +1281,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
|
|||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
z-index: 1;
|
||||
-webkit-border-radius: 50%;
|
||||
-webkit-border-radius: 50%;
|
||||
-moz-border-radius: 50%;
|
||||
border-radius: 350%;
|
||||
overflow: hidden;
|
||||
|
@ -1457,7 +1478,8 @@ a:hover .dashboard-stats-square {
|
|||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.dashboard-recent-media {
|
||||
width: 100%;
|
||||
|
@ -2182,8 +2204,8 @@ span.settings-warning {
|
|||
padding-left: 10px;
|
||||
}
|
||||
#menu_link_show_advanced_settings.active {
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
color: #eee;
|
||||
background-color: #cc7b19;
|
||||
}
|
||||
#configUpdate .form-group,
|
||||
#configUpdate .checkbox{
|
||||
|
@ -2833,6 +2855,30 @@ a .home-platforms-list-cover-face:hover
|
|||
overflow: hidden;
|
||||
max-width: 350px;
|
||||
}
|
||||
.circle {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 50%;
|
||||
border: 0.2rem solid #eeeeee;
|
||||
}
|
||||
.circle-quarter {
|
||||
background-image:
|
||||
linear-gradient(00deg, #2b2b2b 50%, transparent 50%),
|
||||
linear-gradient(270deg, #eeeeee 50%, transparent 50%);
|
||||
}
|
||||
.circle-half {
|
||||
background-image:
|
||||
linear-gradient(90deg, #2b2b2b 50%, transparent 50%),
|
||||
linear-gradient(-90deg, #eeeeee 50%, transparent 50%);
|
||||
}
|
||||
.circle-three-quarter {
|
||||
background-image:
|
||||
linear-gradient(180deg, transparent 50%, #eeeeee 50%),
|
||||
linear-gradient(-90deg, #eeeeee 50%, transparent 50%);
|
||||
}
|
||||
.circle-full {
|
||||
background: #eeeeee;
|
||||
}
|
||||
#graph-tabs {
|
||||
padding-bottom: 10px;
|
||||
float: none;
|
||||
|
@ -2893,7 +2939,7 @@ a .home-platforms-list-cover-face:hover
|
|||
margin-bottom: -20px;
|
||||
width: 100%;
|
||||
max-width: 1750px;
|
||||
overflow: hidden;
|
||||
display: flow-root;
|
||||
}
|
||||
.table-card-back td {
|
||||
font-size: 12px;
|
||||
|
@ -2963,14 +3009,15 @@ a .home-platforms-list-cover-face:hover
|
|||
max-width: 900px;
|
||||
}
|
||||
.stacked-configs > li > span {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
width: inherit;
|
||||
padding: 8px 20px 8px 15px;
|
||||
color: #eee;
|
||||
border-left: 2px solid #444;
|
||||
border-top: 1px solid #2d2d2d;
|
||||
-webkit-transition: all 0.3s ease;
|
||||
-o-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
-o-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.stacked-configs > li > span:hover,
|
||||
.stacked-configs > li > span:focus {
|
||||
|
@ -4278,6 +4325,10 @@ a:hover .overlay-refresh-image:hover {
|
|||
.stream-info tr:nth-child(even) td {
|
||||
background-color: rgba(255,255,255,0.010);
|
||||
}
|
||||
.stream-info td:nth-child(3),
|
||||
.stream-info th:nth-child(3) {
|
||||
width: 25px;
|
||||
}
|
||||
.number-input {
|
||||
margin: 0 !important;
|
||||
width: 55px !important;
|
||||
|
@ -4524,12 +4575,32 @@ a.donate-with-crypto::after {
|
|||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
#crypto-select {
|
||||
width: 280px;
|
||||
margin: 15px auto;
|
||||
}
|
||||
#crypto-qrcode {
|
||||
width: 258px;
|
||||
padding: 0;
|
||||
margin: 15px auto;
|
||||
line-height: 0;
|
||||
text-align: center;
|
||||
background-color: #eee;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
#crypto-address {
|
||||
margin: 15px auto;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#api_qr_code {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 10px;
|
||||
line-height: 1;
|
||||
line-height: 0;
|
||||
text-align: center;
|
||||
background-color: #eee;
|
||||
border: 1px solid #ccc;
|
||||
|
|
|
@ -62,7 +62,7 @@ DOCUMENTATION :: END
|
|||
% if session is not None:
|
||||
<%
|
||||
from collections import defaultdict
|
||||
from plexpy.helpers import cast_to_int, page, short_season
|
||||
from plexpy.helpers import cast_to_int, get_percent, page, short_season
|
||||
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES, EXTRA_TYPES
|
||||
import plexpy
|
||||
%>
|
||||
|
@ -74,6 +74,7 @@ DOCUMENTATION :: END
|
|||
parent_href = page('info', data['parent_rating_key'])
|
||||
grandparent_href = page('info', data['grandparent_rating_key'])
|
||||
user_href = page('user', data['user_id']) if data['user_id'] else '#'
|
||||
library_href = page('library', data['section_id']) if data['section_id'] else '#'
|
||||
season = short_season(data['parent_title'])
|
||||
%>
|
||||
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
|
||||
|
@ -160,7 +161,8 @@ DOCUMENTATION :: END
|
|||
</li>
|
||||
<li class="dashboard-activity-info-item">
|
||||
<div class="sub-heading">Quality</div>
|
||||
<div class="sub-value platform-right" id="stream_quality-${sk}">
|
||||
<div class="sub-value platform-right">
|
||||
<span id="stream_quality-${sk}">
|
||||
% if data['media_type'] != 'photo' and data['quality_profile'] != 'Unknown':
|
||||
<%
|
||||
br = cast_to_int(data['stream_bitrate']) or ''
|
||||
|
@ -174,6 +176,8 @@ DOCUMENTATION :: END
|
|||
% else:
|
||||
${data['quality_profile']}
|
||||
% endif
|
||||
</span>
|
||||
<span data-toggle="tooltip" title="Quality profile is only an estimate based on bitrate and may be incorrect"><i class="fa fa-exclamation-circle"></i></span>
|
||||
</div>
|
||||
</li>
|
||||
% if data['optimized_version'] == 1:
|
||||
|
@ -231,11 +235,14 @@ DOCUMENTATION :: END
|
|||
<div class="sub-value" id="video_decision-${sk}">
|
||||
% if data['media_type'] in ('movie', 'episode', 'clip') and data['stream_video_decision']:
|
||||
<%
|
||||
if data['video_dynamic_range'] == 'HDR':
|
||||
if data['video_dynamic_range'] != 'SDR':
|
||||
video_dynamic_range = ' ' + data['video_dynamic_range']
|
||||
else:
|
||||
video_dynamic_range = ''
|
||||
if data['stream_video_dynamic_range'] != 'SDR' or video_dynamic_range:
|
||||
stream_video_dynamic_range = ' ' + data['stream_video_dynamic_range']
|
||||
else:
|
||||
video_dynamic_range = stream_video_dynamic_range = ''
|
||||
stream_video_dynamic_range = ''
|
||||
%>
|
||||
% if data['stream_video_decision'] == 'transcode':
|
||||
<%
|
||||
|
@ -259,12 +266,15 @@ DOCUMENTATION :: END
|
|||
<div class="sub-heading">Audio</div>
|
||||
<div class="sub-value" id="audio_decision-${sk}">
|
||||
% if data['stream_audio_decision']:
|
||||
<%
|
||||
audio_language = (data['audio_language'] or 'Unknown') + ' - ' if data['media_type'] != 'track' else ''
|
||||
%>
|
||||
% if data['stream_audio_decision'] == 'transcode':
|
||||
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
Transcode (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
% elif data['stream_audio_decision'] == 'copy':
|
||||
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
Direct Stream (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
% else:
|
||||
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
Direct Play (${audio_language}${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
|
@ -279,13 +289,13 @@ DOCUMENTATION :: END
|
|||
subtitle_codec = 'None' if data['stream_subtitle_codec'] and data['stream_subtitle_transient'] else data['subtitle_codec'].upper()
|
||||
%>
|
||||
% if data['stream_subtitle_decision'] == 'transcode':
|
||||
Transcode (${subtitle_codec} <i class="fa fa-long-arrow-right"></i> ${data['stream_subtitle_codec'].upper()})
|
||||
Transcode (${data['subtitle_language'] or 'Unknown'} - ${subtitle_codec} <i class="fa fa-long-arrow-right"></i> ${data['stream_subtitle_codec'].upper()})
|
||||
% elif data['stream_subtitle_decision'] == 'copy':
|
||||
Direct Stream (${subtitle_codec})
|
||||
Direct Stream (${data['subtitle_language'] or 'Unknown'} - ${subtitle_codec})
|
||||
% elif data['stream_subtitle_decision'] == 'burn':
|
||||
Burn (${subtitle_codec})
|
||||
Burn (${data['subtitle_language'] or 'Unknown'} - ${subtitle_codec})
|
||||
% else:
|
||||
Direct Play (${subtitle_codec if data['synced_version'] else data['stream_subtitle_codec'].upper()})
|
||||
Direct Play (${data['subtitle_language'] or 'Unknown'} - ${subtitle_codec if data['synced_version'] else data['stream_subtitle_codec'].upper()})
|
||||
% endif
|
||||
% else:
|
||||
None
|
||||
|
@ -360,7 +370,7 @@ DOCUMENTATION :: END
|
|||
% if data['media_type'] != 'photo':
|
||||
<div class="dashboard-activity-info-time">
|
||||
% if data['live']:
|
||||
<br /><span class="thumb-tooltip" data-toggle="popover" data-img="${data['channel_thumb']}" data-height="40" data-width="40">${data['channel_call_sign']} ${data['channel_identifier']}</span>
|
||||
<br /><span class="thumb-tooltip dashboard-activity-info-channel" data-toggle="popover" data-img="${data['channel_thumb']}" data-height="40" data-width="40">${data['channel_title'] or (data['channel_vcn'] + ' ' + data['channel_call_sign'])}</span>
|
||||
% elif data['view_offset']:
|
||||
ETA:
|
||||
<span id="stream-eta-${sk}">
|
||||
|
@ -392,7 +402,8 @@ DOCUMENTATION :: END
|
|||
% if data['live']:
|
||||
<div id="progress-bar-${sk}" class="progress-bar" style="width: 100%" data-state="live" data-toggle="tooltip" title="Stream Progress Live">Live</div>
|
||||
% else:
|
||||
<div id="buffer-bar-${sk}" class="buffer-bar" style="width: ${data['transcode_progress']}%" data-toggle="tooltip" title="Transcoder Progress ${data['transcode_progress']}%">${data['transcode_progress']}%</div>
|
||||
<% transcode_progress = get_percent(data['transcode_max_offset_available'] * 1000, data['duration']) or data['transcode_progress'] %>
|
||||
<div id="buffer-bar-${sk}" class="buffer-bar" style="width: ${transcode_progress}%" data-toggle="tooltip" title="Transcoder Progress ${transcode_progress}%">${transcode_progress}%</div>
|
||||
<div id="progress-bar-${sk}" class="progress-bar" style="width: ${data['progress_percent']}%" data-last_view_offset="${data['view_offset']}" data-view_offset="${data['view_offset']}" data-stream_duration="${data['stream_duration']}" data-state="${data['state']}" data-toggle="tooltip" title="Stream Progress ${data['progress_percent']}%">${data['progress_percent']}%</div>
|
||||
% endif
|
||||
</div>
|
||||
|
@ -453,21 +464,27 @@ DOCUMENTATION :: END
|
|||
<div class="dashboard-activity-metadata-subtitle-container">
|
||||
% if data['live']:
|
||||
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Live TV">
|
||||
<i class="fa fa-fw fa-broadcast-tower"></i>
|
||||
<a href="${library_href}">
|
||||
<i class="fa fa-fw fa-broadcast-tower"></i>
|
||||
</a>
|
||||
</div>
|
||||
% elif data['channel_stream'] == 0:
|
||||
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
|
||||
% if data['media_type'] == 'movie':
|
||||
<i class="fa fa-fw fa-film"></i>
|
||||
% elif data['media_type'] == 'episode':
|
||||
<i class="fa fa-fw fa-television"></i>
|
||||
% elif data['media_type'] == 'track':
|
||||
<i class="fa fa-fw fa-music"></i>
|
||||
% elif data['media_type'] == 'photo':
|
||||
<i class="fa fa-fw fa-picture-o"></i>
|
||||
% elif data['media_type'] == 'clip':
|
||||
<i class="fa fa-fw fa-video-camera"></i>
|
||||
% endif
|
||||
<a href="${library_href}">
|
||||
% if data['media_type'] == 'movie':
|
||||
<i class="fa fa-fw fa-film"></i>
|
||||
% elif data['media_type'] == 'episode':
|
||||
<i class="fa fa-fw fa-television"></i>
|
||||
% elif data['media_type'] == 'track':
|
||||
<i class="fa fa-fw fa-music"></i>
|
||||
% elif data['media_type'] == 'photo':
|
||||
<i class="fa fa-fw fa-picture-o"></i>
|
||||
% elif data['media_type'] == 'clip':
|
||||
<i class="fa fa-fw fa-video-camera"></i>
|
||||
% else:
|
||||
<i class="fa fa-fw fa-question-circle"></i>
|
||||
% endif
|
||||
</a>
|
||||
</div>
|
||||
% else:
|
||||
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">
|
||||
|
@ -530,4 +547,4 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
|
|
|
@ -20,6 +20,7 @@ DOCUMENTATION :: END
|
|||
export = exporter.Export()
|
||||
thumb_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[0]])
|
||||
art_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[1]])
|
||||
logo_media_types = ', '.join([export.PLURAL_MEDIA_TYPES[k] for k, v in export.MEDIA_TYPES.items() if v[2]])
|
||||
%>
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
|
@ -144,6 +145,22 @@ DOCUMENTATION :: END
|
|||
Select the level to export background artwork image files.<br>Note: Only applies to ${art_media_types}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="export_logo_level">Logo Image Export Level</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="export_logo_level" name="export_logo_level">
|
||||
<option value="0" selected>Level 0 - None / Custom</option>
|
||||
<option value="1">Level 1 - Uploaded and Selected Logos Only</option>
|
||||
<option value="2">Level 2 - Selected and Locked Logos Only</option>
|
||||
<option value="9">Level 9 - All Selected Logos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Select the level to export logo image files.<br>Note: Only applies to ${logo_media_types}.
|
||||
</p>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Warning: Exporting images may take a long time! Images will be saved to a folder alongside the data file.
|
||||
</p>
|
||||
|
@ -226,11 +243,12 @@ DOCUMENTATION :: END
|
|||
getExportFields();
|
||||
|
||||
$('#export_file_format').on('change', function() {
|
||||
if ($(this).val() === 'm3u8') {
|
||||
if ($(this).val() === 'm3u') {
|
||||
$('#export_metadata_level').prop('disabled', true);
|
||||
$('#export_media_info_level').prop('disabled', true);
|
||||
$("#export_thumb_level").prop('disabled', true);
|
||||
$("#export_art_level").prop('disabled', true);
|
||||
$("#export_logo_level").prop('disabled', true);
|
||||
export_custom_metadata_fields.disable();
|
||||
export_custom_media_info_fields.disable();
|
||||
} else {
|
||||
|
@ -238,6 +256,7 @@ DOCUMENTATION :: END
|
|||
$('#export_media_info_level').prop('disabled', false);
|
||||
$("#export_thumb_level").prop('disabled', false);
|
||||
$("#export_art_level").prop('disabled', false);
|
||||
$("#export_logo_level").prop('disabled', false);
|
||||
export_custom_metadata_fields.enable();
|
||||
export_custom_media_info_fields.enable();
|
||||
}
|
||||
|
@ -252,6 +271,7 @@ DOCUMENTATION :: END
|
|||
var file_format = $('#export_file_format option:selected').val();
|
||||
var thumb_level = $("#export_thumb_level option:selected").val();
|
||||
var art_level = $("#export_art_level option:selected").val();
|
||||
var logo_level = $("#export_logo_level option:selected").val();
|
||||
var custom_fields = [
|
||||
$('#export_custom_metadata_fields').val(),
|
||||
$('#export_custom_media_info_fields').val()
|
||||
|
@ -270,6 +290,7 @@ DOCUMENTATION :: END
|
|||
file_format: file_format,
|
||||
thumb_level: thumb_level,
|
||||
art_level: art_level,
|
||||
logo_level: logo_level,
|
||||
custom_fields: custom_fields,
|
||||
export_type: export_type,
|
||||
individual_files: individual_files
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<%inherit file="base.html"/>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
</%def>
|
||||
|
@ -14,17 +15,15 @@
|
|||
<div class="button-bar">
|
||||
<div class="btn-group" id="user-selection">
|
||||
<label>
|
||||
<select name="graph-user" id="graph-user" class="btn" style="color: inherit;">
|
||||
<option value="">All Users</option>
|
||||
<option disabled>────────────</option>
|
||||
<select name="graph-user" id="graph-user" multiple>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-right: 2px;" data-toggle="buttons" id="yaxis-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-plays" value="plays" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-duration" value="duration" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
</div>
|
||||
|
@ -138,6 +137,20 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="concurrent-graph">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-video-camera"></i> Daily concurrent stream count</span> by stream type <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The total count of concurrent streams of tv, movies, and music by the transcode decision.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_concurrent_streams_by_stream_type">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-expand-arrows-alt"></i> <span class="yaxis-text">Play count</span> by source resolution <small>Last <span class="days">30</span> days</small></h4>
|
||||
|
@ -225,6 +238,7 @@
|
|||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="${http_root}js/bootstrap-select.min.js"></script>
|
||||
<script src="${http_root}js/highcharts.min.js"></script>
|
||||
<script src="${http_root}js/jquery.dataTables.min.js"></script>
|
||||
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
|
||||
|
@ -287,6 +301,10 @@
|
|||
return obj;
|
||||
}, {});
|
||||
|
||||
if (!("Total" in chart_visibility)) {
|
||||
chart_visibility["Total"] = false;
|
||||
}
|
||||
|
||||
return data_series.map(function(s) {
|
||||
var obj = Object.assign({}, s);
|
||||
obj.visible = (chart_visibility[s.name] !== false);
|
||||
|
@ -312,7 +330,9 @@
|
|||
'Live TV': '#19A0D7',
|
||||
'Direct Play': '#E5A00D',
|
||||
'Direct Stream': '#FFFFFF',
|
||||
'Transcode': '#F06464'
|
||||
'Transcode': '#F06464',
|
||||
'Max. Concurrent Streams': '#96C83C',
|
||||
'Total': '#96C83C'
|
||||
};
|
||||
var series_colors = [];
|
||||
$.each(data_series, function(index, series) {
|
||||
|
@ -327,6 +347,7 @@
|
|||
<script src="${http_root}js/graphs/plays_by_platform.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_user.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_stream_type.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/concurrent_streams_by_stream_type.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_source_resolution.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_stream_resolution.js${cache_param}"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.js${cache_param}"></script>
|
||||
|
@ -356,6 +377,10 @@
|
|||
break
|
||||
}
|
||||
|
||||
if (window.location.hash === '#concurrent-graph') {
|
||||
current_tab = '#tabs-stream';
|
||||
}
|
||||
|
||||
$('#yaxis-' + yaxis).prop('checked', true);
|
||||
$('#yaxis-' + yaxis).closest('label').addClass('active');
|
||||
$('#graph-days').val(current_day_range);
|
||||
|
@ -364,8 +389,8 @@
|
|||
//$(current_tab).addClass('active');
|
||||
|
||||
|
||||
$('.days').html(current_day_range);
|
||||
$('.months').html(current_month_range);
|
||||
$('.days').text(current_day_range);
|
||||
$('.months').text(current_month_range);
|
||||
|
||||
// Load user ids and names (for the selector)
|
||||
$.ajax({
|
||||
|
@ -373,14 +398,35 @@
|
|||
type: 'get',
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var select = $('#graph-user');
|
||||
let select = $('#graph-user');
|
||||
let by_id = {};
|
||||
data.sort(function(a, b) {
|
||||
return a.friendly_name.localeCompare(b.friendly_name);
|
||||
});
|
||||
data.forEach(function(item) {
|
||||
select.append('<option value="' + item.user_id + '">' +
|
||||
item.friendly_name + '</option>');
|
||||
by_id[item.user_id] = item.friendly_name;
|
||||
});
|
||||
select.selectpicker({
|
||||
countSelectedText: function(sel, total) {
|
||||
if (sel === 0 || sel === total) {
|
||||
return 'All users';
|
||||
} else if (sel > 1) {
|
||||
return sel + ' users';
|
||||
} else {
|
||||
return select.val().map(function(id) {
|
||||
return by_id[id];
|
||||
}).join(', ');
|
||||
}
|
||||
},
|
||||
style: 'btn-dark',
|
||||
actionsBox: true,
|
||||
selectedTextFormat: 'count',
|
||||
noneSelectedText: 'All users'
|
||||
});
|
||||
select.selectpicker('render');
|
||||
select.selectpicker('selectAll');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -519,6 +565,33 @@
|
|||
}
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "get_concurrent_streams_by_stream_type",
|
||||
type: 'get',
|
||||
data: { time_range: time_range, user_id: selected_user_id },
|
||||
dataType: "json",
|
||||
success: function(data) {
|
||||
var dateArray = [];
|
||||
$.each(data.categories, function (i, day) {
|
||||
dateArray.push(moment(day, 'YYYY-MM-DD').valueOf());
|
||||
// Highlight the weekend
|
||||
if ((moment(day, 'YYYY-MM-DD').format('ddd') == 'Sat') ||
|
||||
(moment(day, 'YYYY-MM-DD').format('ddd') == 'Sun')) {
|
||||
hc_plays_by_day_options.xAxis.plotBands.push({
|
||||
from: i-0.5,
|
||||
to: i+0.5,
|
||||
color: 'rgba(80,80,80,0.3)'
|
||||
});
|
||||
}
|
||||
});
|
||||
hc_concurrent_streams_by_stream_type_options.yAxis.min = 0;
|
||||
hc_concurrent_streams_by_stream_type_options.xAxis.categories = dateArray;
|
||||
hc_concurrent_streams_by_stream_type_options.series = getGraphVisibility(hc_concurrent_streams_by_stream_type_options.chart.renderTo, data.series);
|
||||
hc_concurrent_streams_by_stream_type_options.colors = getGraphColors(data.series);
|
||||
var hc_plays_by_stream_type = new Highcharts.Chart(hc_concurrent_streams_by_stream_type_options);
|
||||
}
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "get_plays_by_source_resolution",
|
||||
type: 'get',
|
||||
|
@ -575,7 +648,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
$('#nav-tabs-2').tab('show');
|
||||
$('#nav-tabs-stream').tab('show');
|
||||
}
|
||||
|
||||
function loadGraphsTab3(time_range, yaxis) {
|
||||
|
@ -602,11 +675,6 @@
|
|||
$('#nav-tabs-total').tab('show');
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
|
||||
// Tab1 opened
|
||||
$('#nav-tabs-plays').on('shown.bs.tab', function (e) {
|
||||
e.preventDefault();
|
||||
|
@ -639,7 +707,7 @@
|
|||
setLocalStorage('graph_days', current_day_range);
|
||||
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
$('.days').html(current_day_range);
|
||||
$('.days').text(current_day_range);
|
||||
});
|
||||
|
||||
// Month range changed
|
||||
|
@ -649,12 +717,23 @@
|
|||
current_month_range = $(this).val();
|
||||
setLocalStorage('graph_months', current_month_range);
|
||||
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
$('.months').html(current_month_range);
|
||||
$('.months').text(current_month_range);
|
||||
});
|
||||
|
||||
let graph_user_last_id = undefined;
|
||||
|
||||
// User changed
|
||||
$('#graph-user').on('change', function() {
|
||||
selected_user_id = $(this).val() || null;
|
||||
let val = $(this).val();
|
||||
if (val.length === 0 || val.length === $(this).children().length) {
|
||||
selected_user_id = null; // if all users are selected, just send an empty list
|
||||
} else {
|
||||
selected_user_id = val.join(",");
|
||||
}
|
||||
if (selected_user_id === graph_user_last_id) {
|
||||
return;
|
||||
}
|
||||
graph_user_last_id = selected_user_id;
|
||||
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
|
@ -681,6 +760,7 @@
|
|||
if (this.points.length > 1) {
|
||||
var total = 0;
|
||||
$.each(this.points, function(i, point) {
|
||||
if (point.series.name === 'Total') return;
|
||||
s += '<br/>'+point.series.name+': '+point.y;
|
||||
total += point.y;
|
||||
});
|
||||
|
@ -707,6 +787,7 @@
|
|||
if (this.points.length > 1) {
|
||||
var total = 0;
|
||||
$.each(this.points, function(i, point) {
|
||||
if (point.series.name === 'Total') return;
|
||||
s += '<br/>'+point.series.name+': '+moment.duration(point.y, 'hours').format('D [days] H [hrs] m [mins]');
|
||||
total += point.y;
|
||||
});
|
||||
|
@ -727,6 +808,7 @@
|
|||
|
||||
hc_plays_by_day_options.xAxis.plotBands = [];
|
||||
hc_plays_by_stream_type_options.xAxis.plotBands = [];
|
||||
hc_concurrent_streams_by_stream_type_options.xAxis.plotBands = [];
|
||||
|
||||
hc_plays_by_day_options.yAxis.labels.formatter = yaxis_format;
|
||||
hc_plays_by_dayofweek_options.yAxis.labels.formatter = yaxis_format;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<%inherit file="base.html"/>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
|
||||
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
|
||||
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
|
||||
|
@ -31,35 +32,33 @@
|
|||
% if _session['user_group'] == 'admin':
|
||||
<div class="btn-group" id="user-selection">
|
||||
<label>
|
||||
<select name="history-user" id="history-user" class="btn" style="color: inherit;">
|
||||
<option value="">All Users</option>
|
||||
<option disabled>────────────</option>
|
||||
<select name="history-user" id="history-user" multiple>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-movie" value="movie" autocomplete="off"><i class="fa fa-film"></i> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-episode" value="episode" autocomplete="off"><i class="fa fa-television"></i> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-track" value="track" autocomplete="off"><i class="fa fa-music"></i> Music
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-live" value="live" autocomplete="off"><i class="fa fa-broadcast-tower"></i> Live TV
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group" data-toggle="buttons" id="transcode_decision-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_play" value="direct play" autocomplete="off"><i class="fa fa-play-circle"></i> Direct Play
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-copy" value="copy" autocomplete="off"><i class="fa fa-stream"></i> Direct Stream
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-transcode" value="transcode" autocomplete="off"><i class="fa fa-server"></i> Transcode
|
||||
</label>
|
||||
</div>
|
||||
|
@ -84,7 +83,7 @@
|
|||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="play_duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -121,6 +120,7 @@
|
|||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="${http_root}js/bootstrap-select.min.js"></script>
|
||||
<script src="${http_root}js/jquery.dataTables.min.js"></script>
|
||||
<script src="${http_root}js/dataTables.colVis.js"></script>
|
||||
<script src="${http_root}js/dataTables.bootstrap.min.js"></script>
|
||||
|
@ -134,17 +134,40 @@
|
|||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function (data) {
|
||||
var select = $('#history-user');
|
||||
let select = $('#history-user');
|
||||
let by_id = {};
|
||||
data.sort(function (a, b) {
|
||||
return a.friendly_name.localeCompare(b.friendly_name);
|
||||
});
|
||||
data.forEach(function (item) {
|
||||
select.append('<option value="' + item.user_id + '">' +
|
||||
item.friendly_name + '</option>');
|
||||
by_id[item.user_id] = item.friendly_name;
|
||||
});
|
||||
select.selectpicker({
|
||||
countSelectedText: function(sel, total) {
|
||||
if (sel === 0 || sel === total) {
|
||||
return 'All users';
|
||||
} else if (sel > 1) {
|
||||
return sel + ' users';
|
||||
} else {
|
||||
return select.val().map(function(id) {
|
||||
return by_id[id];
|
||||
}).join(', ');
|
||||
}
|
||||
},
|
||||
style: 'btn-dark',
|
||||
actionsBox: true,
|
||||
selectedTextFormat: 'count',
|
||||
noneSelectedText: 'All users'
|
||||
});
|
||||
select.selectpicker('render');
|
||||
select.selectpicker('selectAll');
|
||||
}
|
||||
});
|
||||
|
||||
let history_user_last_id = undefined;
|
||||
|
||||
function loadHistoryTable(media_type, transcode_decision, selected_user_id) {
|
||||
history_table_options.ajax = {
|
||||
url: 'get_history',
|
||||
|
@ -187,7 +210,16 @@
|
|||
});
|
||||
|
||||
$('#history-user').on('change', function () {
|
||||
selected_user_id = $(this).val() || null;
|
||||
let val = $(this).val();
|
||||
if (val.length === 0 || val.length === $(this).children().length) {
|
||||
selected_user_id = null; // if all users are selected, just send an empty list
|
||||
} else {
|
||||
selected_user_id = val.join(",");
|
||||
}
|
||||
if (selected_user_id === history_user_last_id) {
|
||||
return;
|
||||
}
|
||||
history_user_last_id = selected_user_id;
|
||||
history_table.draw();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="play_duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
|
@ -77,9 +77,10 @@ DOCUMENTATION :: END
|
|||
<% fallback = 'art-live' if row0['live'] else 'art' %>
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'], row0['rating_key'], 500, 280, 40, '282828', 3, fallback=fallback)});">
|
||||
% elif stat_id == 'top_libraries':
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'], None, 500, 280, 40, '282828', 3, fallback='art')});">
|
||||
<% fallback = 'art-live' if row0['live'] else row0['library_art'] %>
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'] or row0['library_art'], None, 500, 280, 40, '282828', 3, fallback=fallback)});" data-library_art="${row0['library_art']}">
|
||||
% elif stat_id == 'top_users':
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" data-blurhash="${page('pms_image_proxy', row0['user_thumb'] or 'interfaces/default/images/gravatar-default.png', None, 80, 80, 40, '282828', 0, fallback='user')}">
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background" data-blurhash="${page('pms_image_proxy', row0['user_thumb'] or 'interfaces/default/images/gravatar-default.png', None, 100, 100, 40, '282828', 0, fallback='user')}">
|
||||
% elif stat_id == 'top_platforms':
|
||||
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']}-rgba no-image">
|
||||
% else:
|
||||
|
@ -102,22 +103,22 @@ DOCUMENTATION :: END
|
|||
if row0['live']:
|
||||
href = page('info', row0['rating_key'], row0['guid'], history=True, live=row0['live'])
|
||||
else:
|
||||
href = page('info', row0['rating_key'])
|
||||
href = page('info', row0['rating_key'], history=True)
|
||||
%>
|
||||
<a id="stats-thumb-url-${stat_id}" href="${href}" title="${row0['title']}">
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${fallback.split('-')[0]}" style="background-image: url(${page('pms_image_proxy', row0['thumb'], row0['grandparent_rating_key'] or row0['rating_key'], 300, height, fallback=fallback)});"></div>
|
||||
</a>
|
||||
</div>
|
||||
% elif stat_id == 'top_libraries':
|
||||
% if row0['thumb'].startswith('http'):
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat hidden-xs" style="background-image: url(${page('pms_image_proxy', row0['thumb'], None, 80, 80)});"></div>
|
||||
% if row0['library_thumb'].startswith('http'):
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat hidden-xs" style="background-image: url(${page('pms_image_proxy', row0['library_thumb'], None, 100, 100, fallback='cover')});"></div>
|
||||
% else:
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat svg-icon library-${row0['section_type']} hidden-xs"></div>
|
||||
% endif
|
||||
% elif stat_id == 'top_users':
|
||||
<% user_href = page('user', row0['user_id']) if row0['user_id'] else '#' %>
|
||||
<a id="stats-thumb-url-${stat_id}" href="${user_href}" title="${row0['user']}" class="hidden-xs">
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-circle" style="background-image: url(${page('pms_image_proxy', row0['user_thumb'] or 'interfaces/default/images/gravatar-default.png', None, 80, 80, fallback='user')})"></div>
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-circle" style="background-image: url(${page('pms_image_proxy', row0['user_thumb'] or 'interfaces/default/images/gravatar-default.png', None, 100, 100, fallback='user')})"></div>
|
||||
</a>
|
||||
% elif stat_id == 'top_platforms':
|
||||
<div id="stats-thumb-${stat_id}" class="dashboard-stats-flat svg-icon platform-${row0['platform_name']} transparent hidden-xs"></div>
|
||||
|
@ -147,7 +148,8 @@ DOCUMENTATION :: END
|
|||
data-rating_key="${row.get('rating_key')}" data-grandparent_rating_key="${row.get('grandparent_rating_key')}" data-guid="${row.get('guid')}" data-title="${row.get('title')}"
|
||||
data-art="${row.get('art')}" data-thumb="${row.get('thumb')}" data-platform="${row.get('platform_name')}" data-library-type="${row.get('section_type')}"
|
||||
data-user_id="${row.get('user_id')}" data-user="${row.get('user')}" data-friendly_name="${row.get('friendly_name')}" data-user_thumb="${row.get('user_thumb')}"
|
||||
data-last_watch="${row.get('last_watch')}" data-started="${row.get('started')}" data-live="${row.get('live')}">
|
||||
data-last_watch="${row.get('last_watch')}" data-started="${row.get('started')}" data-live="${row.get('live')}"
|
||||
data-library_art="${row.get('library_art', '')}" data-library_thumb="${row.get('library_thumb', '')}">
|
||||
<div class="sub-list">${loop.index + 1}</div>
|
||||
<div class="sub-value">
|
||||
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
|
||||
|
@ -157,7 +159,7 @@ DOCUMENTATION :: END
|
|||
if row['live']:
|
||||
href = page('info', row['rating_key'], row['guid'], history=True, live=row['live'])
|
||||
else:
|
||||
href = page('info', row['rating_key'])
|
||||
href = page('info', row['rating_key'], history=True)
|
||||
%>
|
||||
<a href="${href}" title="${row['title']}">
|
||||
${row['title']}
|
||||
|
@ -175,7 +177,9 @@ DOCUMENTATION :: END
|
|||
% elif stat_id == 'top_platforms':
|
||||
${row['platform']}
|
||||
% elif stat_id == 'most_concurrent':
|
||||
${row['title']}
|
||||
<a href="graphs#concurrent-graph" title="${row['title']}">
|
||||
${row['title']}
|
||||
</a>
|
||||
% endif
|
||||
</div>
|
||||
<div class="sub-count">
|
||||
|
|
After Width: | Height: | Size: 1.1 KiB |
22
data/interfaces/default/images/default_profile.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="120px" height="120px" viewBox="0 0 120 120" enable-background="new 0 0 120 120" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#BEBEBE" d="M31.161,105.119c4.747-3.413,10.392-5.561,16.939-6.444c2.645,1.983,6.611,2.976,11.9,2.976
|
||||
c5.288,0,9.255-0.992,11.9-2.976c6.546,0.884,12.192,3.031,16.938,6.444c-8.325,5.336-18.223,8.431-28.838,8.431
|
||||
C49.385,113.55,39.487,110.455,31.161,105.119z"/>
|
||||
<path fill="#D6D6D6" d="M48.1,98.675l2.737-8.553C42.241,84.96,36.2,72.758,36.2,58.526c0-7.478,1.669-14.397,4.498-20.029
|
||||
c3.436,1.059,7.504,1.669,11.864,1.669c10.86,0,19.907-3.795,21.903-8.822c5.676,6.256,9.334,16.104,9.334,27.182
|
||||
c0,14.232-6.041,26.434-14.637,31.596l2.737,8.553c-2.646,1.983-6.612,2.976-11.9,2.976c-5.248,0-9.194-0.977-11.838-2.929
|
||||
L48.1,98.675z"/>
|
||||
<path fill="#A6A6A6" d="M38.763,73.994C33.496,68.09,30.25,60,30.25,51.075C30.25,33.002,43.57,18.35,60,18.35
|
||||
c16.431,0,29.75,14.65,29.75,32.724c0,8.925-3.247,17.016-8.514,22.92c1.64-4.647,2.563-9.901,2.563-15.468
|
||||
c0-11.078-3.658-20.925-9.334-27.182c-1.996,5.027-11.043,8.822-21.903,8.822c-4.36,0-8.428-0.611-11.864-1.669
|
||||
C37.868,44.128,36.2,51.048,36.2,58.526C36.2,64.093,37.124,69.348,38.763,73.994z"/>
|
||||
<path fill="#D6D6D6" d="M60,113.55c29.575,0,53.55-23.974,53.55-53.55c0-29.575-23.975-53.55-53.55-53.55
|
||||
C30.425,6.45,6.45,30.425,6.45,60C6.45,89.576,30.425,113.55,60,113.55z M60,119.5C27.138,119.5,0.5,92.86,0.5,60
|
||||
S27.138,0.5,60,0.5c32.86,0,59.5,26.64,59.5,59.499C119.5,92.86,92.86,119.5,60,119.5z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -18,6 +18,5 @@
|
|||
],
|
||||
"theme_color": "#282a2d",
|
||||
"background_color": "#282a2d",
|
||||
"display": "standalone",
|
||||
"orientation": "any"
|
||||
"display": "standalone"
|
||||
}
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
@ -1,5 +1,11 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<title>artist</title>
|
||||
<path fill="#fff" d="M9.201 24.681c0-6.699 0-13.358 0-20.035 7.594-1.505 15.157-3.004 22.768-4.513 0 0.172 0 0.319 0 0.465 0 7.067-0.026 14.135 0.021 21.202 0.010 1.498-0.59 2.57-1.716 3.423-1.999 1.512-4.26 2.145-6.751 1.88-0.504-0.054-1.020-0.205-1.481-0.418-1.502-0.695-1.856-2.122-0.908-3.48 0.826-1.184 1.99-1.924 3.302-2.433 1.362-0.528 2.774-0.843 4.252-0.719 0.324 0.027 0.646 0.084 0.994 0.13 0-4.345 0-8.679 0-13.050-6.062 1.204-12.099 2.404-18.16 3.608 0 0.196 0 0.345 0 0.495 0 5.174-0.006 10.349 0.004 15.523 0.003 1.409-0.802 2.302-1.854 3.056-0.889 0.637-1.859 1.114-2.906 1.426-1.524 0.453-3.067 0.627-4.619 0.169-0.952-0.281-1.789-0.736-2.010-1.83-0.136-0.673 0.098-1.269 0.459-1.822 0.772-1.183 1.947-1.853 3.193-2.388 1.662-0.714 3.394-1.043 5.207-0.698 0.048 0.009 0.099 0.005 0.206 0.010z"></path>
|
||||
<path fill="#FFFFFF" d="M29.369,0.72H10.271C8.864,0.722,7.726,1.861,7.724,3.267v18.534c-0.771-0.459-1.649-0.703-2.546-0.709
|
||||
c-2.813,0-5.094,2.281-5.094,5.094s2.281,5.094,5.094,5.094s5.093-2.281,5.093-5.094V10.907h19.099v10.894
|
||||
c-0.771-0.459-1.649-0.703-2.546-0.709c-2.813,0-5.094,2.281-5.094,5.094s2.281,5.094,5.094,5.094s5.093-2.281,5.093-5.094V3.267
|
||||
C31.914,1.861,30.775,0.722,29.369,0.72z M10.271,3.267h19.099V8.36H10.271V3.267z"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 979 B After Width: | Height: | Size: 931 B |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.4 KiB |
|
@ -1,9 +1,36 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>live</title>
|
||||
<path fill="#fff" d="M9.636 10.115c-0.829 0.544-1.243 0.816-2.072 1.361-2.331-3.547-2.331-6.195 0-9.749 0.829 0.546 1.244 0.819 2.072 1.361-1.68 2.557-1.68 4.464 0 7.027z"></path>
|
||||
<path fill="#fff" d="M4.374 11.662c-0.828 0.542-1.243 0.815-2.072 1.359-3.069-4.676-3.069-8.159 0-12.838 0.829 0.546 1.244 0.817 2.072 1.362-2.418 3.684-2.418 6.426 0 10.117z"></path>
|
||||
<path fill="#fff" d="M22.365 10.115c0.826 0.544 1.242 0.816 2.070 1.361 2.334-3.547 2.334-6.195 0-9.749-0.828 0.546-1.244 0.819-2.070 1.361 1.677 2.557 1.677 4.464 0 7.027z"></path>
|
||||
<path fill="#fff" d="M27.627 11.662c0.827 0.542 1.243 0.815 2.070 1.359 3.070-4.676 3.070-8.159 0-12.838-0.827 0.546-1.243 0.817-2.070 1.362 2.419 3.684 2.419 6.426 0 10.117z"></path>
|
||||
<path fill="#fff" d="M25.211 31.982l2.611-0.95-8.172-22.45c0.32-0.589 0.502-1.263 0.502-1.979 0-2.293-1.859-4.152-4.152-4.152s-4.151 1.858-4.151 4.152c0 0.672 0.16 1.305 0.443 1.868l-8.212 22.561 2.612 0.95 1.952-5.362h14.616l1.951 5.362zM17.396 10.513l3.945 10.834-7.903-7.9 1.080-2.966c0.46 0.176 0.96 0.272 1.481 0.272 0.49 0.001 0.961-0.084 1.397-0.24zM12.39 16.329l7.51 7.512h-10.245l2.735-7.512z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<g>
|
||||
<polygon fill="none" points="12.252,15.364 9.287,23.717 20.6,23.709 "/>
|
||||
<path fill="none" d="M13.122,12.92l8.846,8.846l-4.158-11.71c-0.558,0.271-1.175,0.437-1.839,0.437
|
||||
c-0.66,0-1.276-0.164-1.833-0.434L13.122,12.92z"/>
|
||||
<path fill="#FFFFFF" d="M19.663,8.28c0.33-0.601,0.529-1.277,0.529-2.008c0-2.333-1.892-4.223-4.222-4.223s-4.222,1.89-4.222,4.223
|
||||
c0,0.733,0.203,1.412,0.532,2.013l-7.56,21.292c-0.248,0.697,0.047,1.434,0.656,1.654c0.608,0.215,1.305-0.177,1.549-0.865
|
||||
l1.53-4.309l15.034-0.007l1.532,4.315c0.247,0.688,0.941,1.08,1.549,0.865c0.61-0.221,0.904-0.957,0.658-1.654L19.663,8.28z
|
||||
M17.81,10.057l4.158,11.71l-8.846-8.846l1.016-2.861c0.557,0.27,1.173,0.434,1.833,0.434
|
||||
C16.635,10.493,17.252,10.328,17.81,10.057z M12.252,15.364l8.347,8.345L9.287,23.717L12.252,15.364z"/>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M3.638,1.458C3.189,0.996,2.563,0.875,2.279,1.211c-3.043,3.578-3.045,8.859-0.002,12.44
|
||||
c0.284,0.335,0.913,0.214,1.362-0.25c0.452-0.462,0.628-1.022,0.424-1.264C1.76,9.428,1.762,5.433,4.064,2.726
|
||||
C4.267,2.484,4.089,1.924,3.638,1.458z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M28.303,1.46c-0.45,0.464-0.626,1.021-0.421,1.261c2.303,2.71,2.3,6.707-0.003,9.412
|
||||
c-0.205,0.24-0.025,0.802,0.424,1.266c0.448,0.466,1.075,0.586,1.361,0.25c3.044-3.578,3.046-8.86,0.003-12.441
|
||||
C29.38,0.873,28.753,0.996,28.303,1.46z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M8.847,11.926c0.453-0.465,0.687-0.966,0.546-1.132C7.78,8.897,7.781,6.1,9.392,4.205
|
||||
c0.141-0.167-0.094-0.672-0.545-1.138c-0.448-0.463-1.019-0.639-1.24-0.376c-2.351,2.766-2.353,6.848,0,9.616
|
||||
C7.826,12.569,8.398,12.39,8.847,11.926z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M23.182,11.858c0.45,0.466,1.017,0.642,1.24,0.379c2.353-2.766,2.354-6.849,0.001-9.617
|
||||
C24.199,2.362,23.63,2.54,23.18,3.004c-0.449,0.464-0.685,0.967-0.542,1.132c1.611,1.896,1.61,4.692-0.001,6.587
|
||||
C22.495,10.888,22.73,11.393,23.182,11.858z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
@ -1,5 +1,17 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>movie</title>
|
||||
<path fill="#fff" d="M0 1.416c10.695 0 21.315 0 32 0 0 9.719 0 19.417 0 29.168-10.635 0-21.293 0-32 0 0-9.699 0-19.399 0-29.168zM9.202 15.129c4.535 0 9.065 0 13.609 0 0-3.798 0-7.579 0-11.355-4.563 0-9.075 0-13.609 0 0 3.801 0 7.551 0 11.355zM9.215 28.909c4.581 0 9.093 0 13.61 0 0-3.818 0-7.585 0-11.382-4.549 0-9.062 0-13.61 0 0 3.803 0 7.57 0 11.382zM6.813 5.983c0-0.753 0-1.467 0-2.209-1.138 0-2.235 0-3.33 0 0 0.766 0 1.48 0 2.209 1.131 0 2.21 0 3.33 0zM25.231 3.754c0 0.783 0 1.494 0 2.219 1.141 0 2.235 0 3.33 0 0-0.763 0-1.476 0-2.219-1.12 0-2.198 0-3.33 0zM25.233 12.938c0 0.777 0 1.492 0 2.19 1.149 0 2.248 0 3.335 0 0-0.754 0-1.452 0-2.19-1.116 0-2.197 0-3.335 0zM25.227 22.074c0 0.783 0 1.496 0 2.218 1.139 0 2.235 0 3.335 0 0-0.758 0-1.471 0-2.218-1.119 0-2.196 0-3.335 0zM3.472 26.689c0 0.768 0 1.481 0 2.221 1.133 0 2.226 0 3.34 0 0-0.763 0-1.475 0-2.221-1.119 0-2.197 0-3.34 0zM28.579 26.711c-1.112 0-2.225 0-3.353 0 0 0.749 0 1.462 0 2.202 1.137 0 2.233 0 3.353 0 0-0.748 0-1.447 0-2.202zM6.817 15.155c0-0.774 0-1.468 0-2.21-1.133 0-2.229 0-3.338 0 0 0.761 0 1.473 0 2.21 1.127 0 2.207 0 3.338 0zM3.463 19.73c1.165 0 2.242 0 3.346 0 0-0.759 0-1.469 0-2.189-1.143 0-2.239 0-3.346 0 0 0.748 0 1.446 0 2.189zM28.559 19.733c0-0.764 0-1.479 0-2.188-1.144 0-2.241 0-3.34 0 0 0.752 0 1.448 0 2.188 1.114 0 2.195 0 3.34 0zM6.791 24.304c0-0.798 0-1.511 0-2.215-1.145 0-2.225 0-3.312 0 0 0.766 0 1.481 0 2.215 1.129 0 2.211 0 3.312 0zM3.489 8.378c0 0.771 0 1.464 0 2.145 1.138 0 2.219 0 3.319 0 0-0.74 0-1.431 0-2.145-1.129 0-2.211 0-3.319 0zM28.554 10.538c0-0.775 0-1.464 0-2.137-1.146 0-2.242 0-3.319 0 0 0.747 0 1.438 0 2.137 1.131 0 2.21 0 3.319 0z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px"
|
||||
height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<g id="Layer_1">
|
||||
<title>artist</title>
|
||||
</g>
|
||||
<g id="Layer_2">
|
||||
<path fill="#FFFFFF" d="M29.508,0.241H2.492c-1.242,0-2.251,1.008-2.251,2.251v27.015c0,1.242,1.009,2.252,2.251,2.252h27.016
|
||||
c1.242,0,2.251-1.01,2.251-2.252V2.493C31.759,1.25,30.75,0.241,29.508,0.241z M4.744,29.508H2.492v-3.002h2.252V29.508z
|
||||
M4.744,24.255H2.492v-3.753h2.252V24.255z M4.744,18.252H2.492V14.5h2.252V18.252z M4.744,12.248H2.492V8.496h2.252V12.248z
|
||||
M4.744,6.245H2.492V2.493h2.252V6.245z M25.005,29.508H6.995V16.75h18.01V29.508z M25.005,14.5H6.995V2.493h18.01V14.5z
|
||||
M29.508,29.508h-2.252v-3.002h2.252V29.508z M29.508,24.255h-2.252v-3.753h2.252V24.255z M29.508,18.252h-2.252V14.5h2.252V18.252
|
||||
z M29.508,12.248h-2.252V8.496h2.252V12.248z M29.508,6.245h-2.252V2.493h2.252V6.245z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
@ -1,6 +1,25 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>photo</title>
|
||||
<path fill="#fff" d="M25.208 2.266c0 1.514 0 2.976 0 4.513 2.281 0 4.505 0 6.792 0 0 7.681 0 15.287 0 22.955-10.643 0-21.3 0-32 0 0-7.626 0-15.236 0-22.913 2.258 0 4.486 0 6.792 0 0-1.546 0-3.029 0-4.555 6.152 0 12.226 0 18.415 0zM16.008 9.209c-5.026-0.004-9.12 4.079-9.123 9.099-0.004 4.961 4.099 9.069 9.074 9.085 5.022 0.016 9.124-4.047 9.159-9.070 0.035-4.985-4.087-9.109-9.109-9.114z"></path>
|
||||
<path fill="#fff" d="M20.601 18.292c0.003 2.551-2.070 4.626-4.61 4.613-2.558-0.013-4.595-2.069-4.591-4.634 0.003-2.524 2.038-4.557 4.577-4.572 2.562-0.015 4.621 2.030 4.624 4.593z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px"
|
||||
height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<g id="Layer_2">
|
||||
</g>
|
||||
<g id="Layer_3">
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M30.807,8.975c-0.587-0.596-1.388-0.933-2.224-0.934h-5.158l-0.855-2.599
|
||||
c-0.207-0.633-0.607-1.184-1.145-1.577c-0.536-0.389-1.18-0.6-1.84-0.6h-7.171c-0.661,0-1.306,0.211-1.84,0.602
|
||||
C10.037,4.259,9.637,4.811,9.43,5.442L8.573,8.041H3.416C2.579,8.042,1.779,8.379,1.192,8.975c-0.591,0.6-0.923,1.408-0.923,2.25
|
||||
V25.55c0,0.843,0.332,1.65,0.923,2.25c0.587,0.596,1.387,0.932,2.224,0.934h25.167c0.836-0.002,1.637-0.338,2.224-0.934
|
||||
c0.591-0.6,0.923-1.407,0.923-2.25V11.225C31.729,10.383,31.397,9.574,30.807,8.975z M29.482,25.55
|
||||
c0,0.258-0.103,0.497-0.273,0.672c-0.165,0.168-0.391,0.265-0.627,0.266H3.418c-0.237-0.001-0.462-0.098-0.628-0.266
|
||||
c-0.176-0.181-0.274-0.422-0.273-0.674v-14.32c0-0.258,0.103-0.499,0.273-0.673c0.172-0.173,0.396-0.266,0.627-0.267h6.782
|
||||
l1.365-4.144c0.063-0.189,0.181-0.351,0.334-0.463c0.152-0.11,0.332-0.169,0.514-0.169h7.175c0.182,0,0.361,0.059,0.514,0.169
|
||||
c0.153,0.112,0.271,0.273,0.335,0.463l1.363,4.144h6.783c0.23,0,0.456,0.094,0.627,0.267c0.171,0.174,0.272,0.413,0.273,0.671
|
||||
V25.55z"/>
|
||||
<path fill="#FFFFFF" d="M16,10.7c-3.764,0-6.813,3.049-6.813,6.812c0,3.765,3.05,6.815,6.813,6.815
|
||||
c3.763,0,6.813-3.051,6.813-6.815C22.813,13.749,19.763,10.7,16,10.7z M15.999,22.082c-2.523,0-4.568-2.045-4.568-4.568
|
||||
s2.045-4.569,4.568-4.569c2.524,0,4.569,2.046,4.569,4.569S18.523,22.082,15.999,22.082z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 875 B |
|
@ -1,12 +1,9 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>playlist</title>
|
||||
<path fill="#fff" d="M9.167 10.268c7.653 0 15.208 0 22.82 0 0 1.147 0 2.268 0 3.451-7.583 0-15.172 0-22.82 0 0-1.134 0-2.274 0-3.451z"></path>
|
||||
<path fill="#fff" d="M9.14 21.73c0-1.152 0-2.257 0-3.424 7.609 0 15.212 0 22.86 0 0 1.132 0 2.252 0 3.424-7.607 0-15.191 0-22.86 0z"></path>
|
||||
<path fill="#fff" d="M9.217 5.679c0-1.145 0-2.23 0-3.363 7.562 0 15.101 0 22.683 0 0 1.113 0 2.213 0 3.363-7.539 0-15.080 0-22.683 0z"></path>
|
||||
<path fill="#fff" d="M9.225 29.708c0-1.132 0-2.214 0-3.336 7.558 0 15.086 0 22.668 0 0 1.086 0 2.186 0 3.336-7.526 0-15.071 0-22.668 0z"></path>
|
||||
<path fill="#fff" d="M0.007 10.279c1.58 0 3.043 0 4.551 0 0 1.153 0 2.272 0 3.444-1.511 0-3.011 0-4.551 0 0-1.157 0-2.292 0-3.444z"></path>
|
||||
<path fill="#fff" d="M4.527 2.292c0 1.183 0 2.284 0 3.424-1.496 0-2.957 0-4.479 0 0-1.142 0-2.276 0-3.424 1.51 0 2.971 0 4.479 0z"></path>
|
||||
<path fill="#fff" d="M4.571 18.3c0 1.151 0 2.254 0 3.416-1.515 0-3.019 0-4.571 0 0-1.127 0-2.247 0-3.416 1.513 0 3.001 0 4.571 0z"></path>
|
||||
<path fill="#fff" d="M4.54 26.352c0 1.137 0 2.218 0 3.354-1.494 0-2.975 0-4.506 0 0-1.094 0-2.192 0-3.354 1.489 0 2.965 0 4.506 0z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<path fill="#FFFFFF" d="M0.27,2.892h2.621v2.622H0.27V2.892z M31.729,2.892H8.135v2.622h23.595V2.892z M31.729,10.757H8.135v2.622
|
||||
h23.595V10.757z M8.135,18.622h23.595v2.621H8.135V18.622z M31.729,26.487H8.135v2.621h23.595V26.487z M2.891,10.757H0.27v2.622
|
||||
h2.621V10.757z M0.27,18.622h2.621v2.621H0.27V18.622z M2.891,26.487H0.27v2.621h2.621V26.487z"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 819 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -1,6 +1,12 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>show</title>
|
||||
<path fill="#fff" d="M0 26.565c0-8.38 0-16.706 0-25.089 10.661 0 21.307 0 32 0 0 8.343 0 16.686 0 25.089-10.637 0-21.283 0-32 0zM3.514 4.937c0 5.355 0 10.634 0 15.901 8.375 0 16.691 0 24.994 0 0-5.331 0-10.612 0-15.901-8.356 0-16.656 0-24.994 0z"></path>
|
||||
<path fill="#fff" d="M6.874 30.524c0-0.553 0-1.056 0-1.602 6.084 0 12.136 0 18.25 0 0 0.509 0 1.029 0 1.602-6.050 0-12.12 0-18.25 0z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<title>artist</title>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M2.359,1.602h27.281c1.255,0,2.273,1.018,2.273,2.273v19.703c0,1.256-1.019,2.273-2.273,2.273H2.359
|
||||
c-1.255,0-2.273-1.018-2.273-2.273V3.875C0.086,2.62,1.104,1.602,2.359,1.602z M2.359,23.578h27.281V3.875H2.359V23.578z"/>
|
||||
<path fill="#FFFFFF" d="M25.094,30.398v-2.273H6.906v2.273H25.094z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 816 B |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
@ -1,5 +1,13 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>video</title>
|
||||
<path fill="#fff" d="M20.662 27.439c-6.971 0-13.797 0-20.662 0 0-7.639 0-15.235 0-22.878 6.846 0 13.672 0 20.588 0 0 2.962 0 5.932 0 9.091 3.87-2.316 7.591-4.542 11.412-6.828 0 6.153 0 12.176 0 18.33-3.769-2.257-7.494-4.488-11.338-6.789 0 3.070 0 6.035 0 9.075z"></path>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<title>artist</title>
|
||||
<path fill="#FFFFFF" d="M21.619,27.237H2.515c-1.241,0-2.247-1.006-2.247-2.247V7.011c0-1.242,1.006-2.248,2.247-2.248h19.104
|
||||
c1.24,0,2.247,1.006,2.247,2.248v4.562l6.091-4.349c0.506-0.36,1.207-0.242,1.568,0.263c0.134,0.19,0.206,0.415,0.207,0.647v15.732
|
||||
c-0.002,0.621-0.509,1.123-1.128,1.119c-0.232-0.001-0.458-0.075-0.647-0.209l-6.091-4.349v4.563
|
||||
C23.866,26.231,22.859,27.237,21.619,27.237z M2.515,7.011V24.99h19.104v-6.742c0.003-0.621,0.509-1.122,1.129-1.118
|
||||
c0.231,0,0.457,0.074,0.646,0.208l6.091,4.349V10.314l-6.091,4.349c-0.506,0.36-1.207,0.242-1.568-0.263
|
||||
c-0.133-0.189-0.206-0.415-0.207-0.647V7.011H2.515z"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 430 B After Width: | Height: | Size: 1.1 KiB |
|
@ -42,10 +42,10 @@
|
|||
<h3 class="pull-left">Watch Statistics</h3>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="watch-stats-toggles" style="margin-right: 3px">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="plays" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="duration" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
</div>
|
||||
|
@ -92,27 +92,27 @@
|
|||
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
|
||||
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
|
||||
<li>
|
||||
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" id="recently-added-page-right" class="paginate btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="recently-added-toggles" style="margin-right: 3px">
|
||||
<label class="btn btn-dark" id="recently-added-label-all">
|
||||
<label class="btn btn-dark btn-filter" id="recently-added-label-all">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="all" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-movies">
|
||||
<label class="btn btn-dark btn-filter" id="recently-added-label-movies">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-movie" value="movie" autocomplete="off"> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-tv">
|
||||
<label class="btn btn-dark btn-filter" id="recently-added-label-tv">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-show" value="show" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-music">
|
||||
<label class="btn btn-dark btn-filter" id="recently-added-label-music">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-artist" value="artist" autocomplete="off"> Music
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-other_video">
|
||||
<label class="btn btn-dark btn-filter" id="recently-added-label-other_video">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-other_video" value="other_video" autocomplete="off"> Videos
|
||||
</label>
|
||||
</div>
|
||||
|
@ -212,28 +212,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% from plexpy.helpers import anon_url %>
|
||||
<div id="python2-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="python2-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title">Unable to Update</h4>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: center;">
|
||||
<p>Tautulli is still running using Python 2 and cannot be updated past v2.6.3.</p>
|
||||
<p>Python 3 is required to continue receiving updates.</p>
|
||||
<p>
|
||||
<strong>Please see the <a href="${anon_url('https://github.com/Tautulli/Tautulli/wiki/Upgrading-to-Python-3-%28Tautulli-v2.5%29')}" target="_blank" rel="noreferrer">wiki</a>
|
||||
for instructions on how to upgrade to Python 3.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Close">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<div class="modal fade" id="ip-info-modal" tabindex="-1" role="dialog" aria-labelledby="ip-info-modal">
|
||||
|
@ -320,6 +298,8 @@
|
|||
|
||||
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
|
||||
|
||||
var title = document.title;
|
||||
|
||||
function getCurrentActivity() {
|
||||
activity_ready = false;
|
||||
|
||||
|
@ -390,6 +370,8 @@
|
|||
|
||||
$('#currentActivityHeader').show();
|
||||
|
||||
document.title = stream_count + ' stream' + (stream_count > 1 ? 's' : '') + ' | ' + title;
|
||||
|
||||
sessions.forEach(function (session) {
|
||||
var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
|
||||
var key = s.session_key;
|
||||
|
@ -483,8 +465,8 @@
|
|||
|
||||
var video_decision = '';
|
||||
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.stream_video_decision) {
|
||||
var v_bd = (s.video_dynamic_range === 'HDR') ? ' ' + s.video_dynamic_range : '';
|
||||
var sv_bd = (s.video_dynamic_range === 'HDR') ? ' ' + s.stream_video_dynamic_range : '';
|
||||
var v_bd = (s.video_dynamic_range !== 'SDR') ? ' ' + s.video_dynamic_range : '';
|
||||
var sv_bd = (s.stream_video_dynamic_range !== 'SDR' || v_bd) ? ' ' + s.stream_video_dynamic_range : '';
|
||||
var v_res= '';
|
||||
switch (s.video_resolution.toLowerCase()) {
|
||||
case 'sd':
|
||||
|
@ -523,14 +505,15 @@
|
|||
|
||||
var audio_decision = '';
|
||||
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.stream_audio_decision) {
|
||||
var audio_language = (s.media_type !== 'track') ? (s.audio_language || 'Unknown') + ' - ' : '';
|
||||
var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase();
|
||||
var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase();
|
||||
if (s.stream_audio_decision === 'transcode') {
|
||||
audio_decision = 'Transcode (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
audio_decision = 'Transcode (' + audio_language + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
} else if (s.stream_audio_decision === 'copy') {
|
||||
audio_decision = 'Direct Stream (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
audio_decision = 'Direct Stream (' + audio_language + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
} else {
|
||||
audio_decision = 'Direct Play (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
audio_decision = 'Direct Play (' + audio_language + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
|
||||
}
|
||||
}
|
||||
$('#audio_decision-' + key).html(audio_decision);
|
||||
|
@ -539,13 +522,13 @@
|
|||
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.subtitles === 1) {
|
||||
var subtitle_codec = (s.stream_subtitle_codec && s.stream_subtitle_transient) ? 'None' : s.subtitle_codec.toUpperCase();
|
||||
if (s.stream_subtitle_decision === 'transcode') {
|
||||
subtitle_decision = 'Transcode (' + subtitle_codec + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_subtitle_codec.toUpperCase() + ')';
|
||||
subtitle_decision = 'Transcode ('+ (s.subtitle_language || 'Unknown')+ ' - ' + subtitle_codec + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_subtitle_codec.toUpperCase() + ')';
|
||||
} else if (s.stream_subtitle_decision === 'copy') {
|
||||
subtitle_decision = 'Direct Stream (' + subtitle_codec + ')';
|
||||
subtitle_decision = 'Direct Stream ('+ (s.subtitle_language || 'Unknown')+ ' - ' + subtitle_codec + ')';
|
||||
} else if (s.stream_subtitle_decision === 'burn') {
|
||||
subtitle_decision = 'Burn (' + subtitle_codec + ')';
|
||||
subtitle_decision = 'Burn ('+ (s.subtitle_language || 'Unknown')+ ' - ' + subtitle_codec + ')';
|
||||
} else {
|
||||
subtitle_decision = 'Direct Play (' + ((s.synced_version === '1') ? subtitle_codec : s.stream_subtitle_codec.toUpperCase()) + ')';
|
||||
subtitle_decision = 'Direct Play ('+ (s.subtitle_language || 'Unknown')+ ' - ' + ((s.synced_version === '1') ? subtitle_codec : s.stream_subtitle_codec.toUpperCase()) + ')';
|
||||
}
|
||||
}
|
||||
$('#subtitle_decision-' + key).html(subtitle_decision);
|
||||
|
@ -583,6 +566,7 @@
|
|||
|
||||
// Update the stream progress times
|
||||
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
|
||||
$('#stream-duration-' + key).html(millisecondsToMinutes(parseInt(s.stream_duration), false));
|
||||
var stream_view_offset = $('#stream-view-offset-' + key);
|
||||
stream_view_offset.data('state', s.state);
|
||||
if (stream_view_offset.data('last_view_offset') !== s.view_offset) {
|
||||
|
@ -590,8 +574,10 @@
|
|||
}
|
||||
|
||||
// Update the progress bars
|
||||
$('#buffer-bar-' + key).css({width: parseInt(s.transcode_progress) + '%'}).html(s.transcode_progress + '%')
|
||||
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
|
||||
var duration = parseInt(s.duration);
|
||||
var transcode_progress = duration ? Math.round(s.transcode_max_offset_available * 1000 / duration * 100) : s.transcode_progress;
|
||||
$('#buffer-bar-' + key).css({width: parseInt(transcode_progress) + '%'}).html(transcode_progress + '%')
|
||||
.attr('data-original-title', 'Transcoder Progress ' + transcode_progress + '%');
|
||||
if (s.live !== 1) {
|
||||
var progress_bar = $('#progress-bar-' + key);
|
||||
progress_bar.data('state', s.state);
|
||||
|
@ -618,6 +604,8 @@
|
|||
} else {
|
||||
$('#currentActivityHeader').hide();
|
||||
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">Nothing is currently being played.</div>');
|
||||
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
activity_ready = true;
|
||||
|
@ -794,6 +782,8 @@
|
|||
var grandparent_rating_key = $(elem).data('grandparent_rating_key');
|
||||
var guid = $(elem).data('guid');
|
||||
var live = $(elem).data('live');
|
||||
var library_art = $(elem).data('library_art');
|
||||
var library_thumb = $(elem).data('library_thumb');
|
||||
var [height, fallback_poster, fallback_art] = [450, 'poster', 'art'];
|
||||
if ($.inArray(stat_id, ['top_music', 'popular_music']) > -1) {
|
||||
[height, fallback_poster, fallback_art] = [300, 'cover', 'art'];
|
||||
|
@ -805,18 +795,18 @@
|
|||
if (stat_id === 'most_concurrent') {
|
||||
return
|
||||
} else if (stat_id === 'top_libraries') {
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art, null, 500, 280, 40, '282828', 3, fallback_art) + ')');
|
||||
$('#stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art || library_art, null, 500, 280, 40, '282828', 3, fallback_art) + ')');
|
||||
$('#stats-thumb-' + stat_id).removeClass(function (index, className) {
|
||||
return (className.match (/(^|\s)svg-icon library-\S+/g) || []).join(' ')});
|
||||
if (thumb.startsWith('http')) {
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', thumb, null, 300, 300, null, null, null, 'cover') + ')');
|
||||
if (library_thumb.startsWith('http')) {
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', library_thumb, null, 100, 100, null, null, null, 'cover') + ')');
|
||||
} else {
|
||||
$('#stats-thumb-' + stat_id).css('background-image', '')
|
||||
.addClass('svg-icon library-' + library_type);
|
||||
}
|
||||
} else if (stat_id === 'top_users') {
|
||||
loadBlurHash($('#stats-background-' + stat_id), page('pms_image_proxy', user_thumb || 'interfaces/default/images/gravatar-default.png', null, 80, 80, 40, '282828', 0, 'user'));
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', user_thumb || 'interfaces/default/images/gravatar-default.png', null, 80, 80, null, null, null, 'user') + ')');
|
||||
loadBlurHash($('#stats-background-' + stat_id), page('pms_image_proxy', user_thumb || 'interfaces/default/images/gravatar-default.png', null, 100, 100, 40, '282828', 0, 'user'));
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', user_thumb || 'interfaces/default/images/gravatar-default.png', null, 100, 100, null, null, null, 'user') + ')');
|
||||
if (user_id) {
|
||||
href = page('user', user_id);
|
||||
}
|
||||
|
@ -841,7 +831,7 @@
|
|||
$('#stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art, img_rating_key, 500, 280, 40, '282828', 3, fallback_art) + ')');
|
||||
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', thumb, img_rating_key, 300, height, null, null, null, fallback_poster) + ')');
|
||||
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(' + page('pms_image_proxy', thumb, img_rating_key, 300, height, 60, '282828', 3, fallback_poster) + ')');
|
||||
$('#library-stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art, img_rating_key, 500, 280, 40, '282828', 3, fallback_art) + ')');
|
||||
$('#library-stats-background-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', art || library_art, img_rating_key, 500, 280, 40, '282828', 3, library_art || fallback_art) + ')');
|
||||
if (thumb.startsWith('http')) {
|
||||
$('#library-stats-thumb-' + stat_id).css('background-image', 'url(' + page('pms_image_proxy', thumb, img_rating_key, 300, 300, null, null, null, 'cover') + ')')
|
||||
.removeClass('svg-icon library-' + stat_id);
|
||||
|
@ -952,10 +942,14 @@
|
|||
count: recently_added_count,
|
||||
media_type: recently_added_type
|
||||
},
|
||||
beforeSend: function () {
|
||||
$(".dashboard-recent-media-row").animate({ scrollLeft: 0 }, 1000);
|
||||
},
|
||||
complete: function (xhr, status) {
|
||||
$("#recentlyAdded").html(xhr.responseText);
|
||||
$('#ajaxMsg').fadeOut();
|
||||
highlightAddedScrollerButton();
|
||||
highlightScrollerButton("#recently-added");
|
||||
paginateScroller("#recently-added", ".paginate-added");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -971,57 +965,11 @@
|
|||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
}
|
||||
|
||||
function highlightAddedScrollerButton() {
|
||||
var scroller = $("#recently-added-row-scroller");
|
||||
var numElems = scroller.find("li:visible").length;
|
||||
scroller.width(numElems * 175);
|
||||
if (scroller.width() > $("body").find(".container-fluid").width()) {
|
||||
$("#recently-added-page-right").removeClass("disabled");
|
||||
} else {
|
||||
$("#recently-added-page-right").addClass("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
$(window).resize(function () {
|
||||
highlightAddedScrollerButton();
|
||||
});
|
||||
|
||||
function resetScroller() {
|
||||
leftTotal = 0;
|
||||
$("#recently-added-row-scroller").animate({ left: leftTotal }, 1000);
|
||||
$("#recently-added-page-left").addClass("disabled").blur();
|
||||
}
|
||||
|
||||
var leftTotal = 0;
|
||||
$(".paginate").click(function (e) {
|
||||
e.preventDefault();
|
||||
var scroller = $("#recently-added-row-scroller");
|
||||
var containerWidth = $("body").find(".container-fluid").width();
|
||||
var scrollAmount = $(this).data("id") * parseInt((containerWidth - 15) / 175) * 175;
|
||||
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
|
||||
|
||||
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
|
||||
scroller.animate({ left: leftTotal }, 250);
|
||||
|
||||
if (leftTotal === 0) {
|
||||
$("#recently-added-page-left").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-added-page-left").removeClass("disabled");
|
||||
}
|
||||
|
||||
if (leftTotal === leftMax) {
|
||||
$("#recently-added-page-right").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-added-page-right").removeClass("disabled");
|
||||
}
|
||||
});
|
||||
|
||||
$('#recently-added-toggles').on('change', function () {
|
||||
$('#recently-added-toggles > label').removeClass('active');
|
||||
selected_filter = $('input[name=recently-added-toggle]:checked', '#recently-added-toggles');
|
||||
$(selected_filter).closest('label').addClass('active');
|
||||
recently_added_type = $(selected_filter).val();
|
||||
resetScroller();
|
||||
setLocalStorage('home_stats_recently_added_type', recently_added_type);
|
||||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
});
|
||||
|
@ -1029,7 +977,6 @@
|
|||
$('#recently-added-count').change(function () {
|
||||
forceMinMax($(this));
|
||||
recently_added_count = $(this).val();
|
||||
resetScroller();
|
||||
setLocalStorage('home_stats_recently_added_count', recently_added_count);
|
||||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
});
|
||||
|
@ -1061,16 +1008,4 @@
|
|||
});
|
||||
</script>
|
||||
% endif
|
||||
% if _session['user_group'] == 'admin':
|
||||
<script>
|
||||
const queryString = window.location.search;
|
||||
const urlParams = new URLSearchParams(queryString);
|
||||
if (urlParams.get('update') === 'python2') {
|
||||
$("#python2-modal").modal({
|
||||
backdrop: 'static',
|
||||
keyboard: false
|
||||
});
|
||||
}
|
||||
</script>
|
||||
% endif
|
||||
</%def>
|
||||
</%def>
|
||||
|
|
|
@ -12,8 +12,10 @@ data :: Usable parameters (if not applicable for media type, blank value will be
|
|||
== Global keys ==
|
||||
rating_key Returns the unique identifier for the media item.
|
||||
media_type Returns the type of media. Either 'movie', 'show', 'season', 'episode', 'artist', 'album', or 'track'.
|
||||
sub_media_type Returns the subtype of media. Either 'movie', 'show', 'season', 'episode', 'artist', 'album', or 'track'.
|
||||
art Returns the location of the item's artwork
|
||||
title Returns the name of the movie, show, episode, artist, album, or track.
|
||||
edition_title Returns the edition title of a movie.
|
||||
duration Returns the standard runtime of the media.
|
||||
content_rating Returns the age rating for the media.
|
||||
summary Returns a brief description of the media plot.
|
||||
|
@ -212,8 +214,8 @@ DOCUMENTATION :: END
|
|||
% if _session['user_group'] == 'admin':
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
% elif data['media_type'] in ('artist', 'album', 'track', 'playlist', 'photo_album', 'photo', 'clip'):
|
||||
<div class="summary-poster-face-track" style="background-image: url(${page('pms_image_proxy', data['thumb'], data['rating_key'], 500, 500, fallback='cover')});">
|
||||
% elif data['media_type'] in ('artist', 'album', 'track', 'playlist', 'photo_album', 'photo', 'clip') or data['sub_media_type'] in ('artist', 'album', 'track'):
|
||||
<div class="summary-poster-face-track" style="background-image: url(${page('pms_image_proxy', data['thumb'], data['rating_key'], 300, 300, fallback='cover')});">
|
||||
<div class="summary-poster-face-overlay">
|
||||
<span></span>
|
||||
</div>
|
||||
|
@ -266,7 +268,7 @@ DOCUMENTATION :: END
|
|||
<h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
|
||||
<h2>${data['title']}</h2>
|
||||
% elif data['media_type'] == 'track':
|
||||
<h1><a href="${page('info', data['grandparent_rating_key'])}">${data['original_title'] or data['grandparent_title']}</a></h1>
|
||||
<h1><a href="${page('info', data['grandparent_rating_key'])}">${data['grandparent_title']}</a></h1>
|
||||
<h2><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a> - ${data['title']}</h2>
|
||||
<h3 class="hidden-xs">T${data['media_index']}</h3>
|
||||
% elif data['media_type'] in ('photo', 'clip'):
|
||||
|
@ -282,14 +284,14 @@ DOCUMENTATION :: END
|
|||
padding_height = ''
|
||||
if data['media_type'] == 'movie' or data['live']:
|
||||
padding_height = 'height: 305px;'
|
||||
elif data['media_type'] in ('show', 'season', 'collection'):
|
||||
padding_height = 'height: 270px;'
|
||||
elif data['media_type'] == 'episode':
|
||||
padding_height = 'height: 70px;'
|
||||
elif data['media_type'] in ('artist', 'album', 'playlist', 'photo_album', 'photo'):
|
||||
elif data['media_type'] in ('artist', 'album', 'playlist', 'photo_album', 'photo') or data['sub_media_type'] in ('artist', 'album', 'track'):
|
||||
padding_height = 'height: 150px;'
|
||||
elif data['media_type'] in ('track', 'clip'):
|
||||
padding_height = 'height: 180px;'
|
||||
elif data['media_type'] == 'episode':
|
||||
padding_height = 'height: 70px;'
|
||||
elif data['media_type'] in ('show', 'season', 'collection'):
|
||||
padding_height = 'height: 270px;'
|
||||
%>
|
||||
<div class="summary-content-padding hidden-xs hidden-sm" style="${padding_height}">
|
||||
% if data['media_type'] in ('movie', 'episode', 'track', 'clip'):
|
||||
|
@ -368,6 +370,11 @@ DOCUMENTATION :: END
|
|||
Studio <strong> ${data['studio']}</strong>
|
||||
% endif
|
||||
</div>
|
||||
<div class="summary-content-details-tag">
|
||||
% if data['media_type'] == 'track' and data['original_title']:
|
||||
Track Artists <strong> ${data['original_title']}</strong>
|
||||
% endif
|
||||
</div>
|
||||
<div class="summary-content-details-tag">
|
||||
% if data['media_type'] == 'movie':
|
||||
Year <strong> ${data['year']}</strong>
|
||||
|
@ -390,14 +397,19 @@ DOCUMENTATION :: END
|
|||
Runtime <strong> <span id="runtime">${data['duration']}</span></strong>
|
||||
% endif
|
||||
</div>
|
||||
% if data['edition_title']:
|
||||
<div class="summary-content-details-tag">
|
||||
Edition <strong> ${data['edition_title']} </strong>
|
||||
</div>
|
||||
% endif
|
||||
<div class="summary-content-details-tag">
|
||||
% if data['content_rating']:
|
||||
Rated <strong> ${data['content_rating']} </strong>
|
||||
% endif
|
||||
</div>
|
||||
<div class="summary-content-details-tag" id="channel-icon">
|
||||
% if media_info['channel_identifier']:
|
||||
Channel <strong> <span class="thumb-tooltip" data-toggle="popover" data-img="${media_info['channel_thumb']}" data-height="40" data-width="40">${media_info['channel_call_sign']} ${media_info['channel_identifier']}</span> </strong>
|
||||
% if media_info['channel_vcn']:
|
||||
Channel <strong> <span class="thumb-tooltip" data-toggle="popover" data-img="${media_info['channel_thumb']}" data-height="40" data-width="40">${media_info['channel_title'] or (media_info['channel_vcn'] + ' ' + media_info['channel_call_sign'])}</span> </strong>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
|
@ -542,7 +554,7 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'):
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track', 'collection', 'playlist'):
|
||||
<div class="col-md-12">
|
||||
<div class="table-card-header">
|
||||
<div class="header-bar">
|
||||
|
@ -571,7 +583,7 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
% endif
|
||||
<%
|
||||
history_type = data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track')
|
||||
history_type = data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track', 'collection', 'playlist')
|
||||
history_active = 'active' if history_type else ''
|
||||
export_active = 'active' if not history_type else ''
|
||||
%>
|
||||
|
@ -634,7 +646,7 @@ DOCUMENTATION :: END
|
|||
<div class="col-md-12">
|
||||
<div class="table-card-header">
|
||||
<div class="header-bar">
|
||||
% if data['media_type'] in ('artist', 'album', 'track'):
|
||||
% if data['media_type'] in ('artist', 'album', 'track', 'playlist'):
|
||||
<span>Play History for <strong>${data['title']}</strong></span>
|
||||
% else:
|
||||
<span>Watch History for <strong>${data['title']}</strong></span>
|
||||
|
@ -649,13 +661,13 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="transcode_decision-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_play" value="direct play" autocomplete="off"><i class="fa fa-play-circle"></i> Direct Play
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_stream" value="copy" autocomplete="off"><i class="fa fa-stream"></i> Direct Stream
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-transcode" value="transcode" autocomplete="off"><i class="fa fa-server"></i> Transcode
|
||||
</label>
|
||||
</div>
|
||||
|
@ -680,7 +692,7 @@ DOCUMENTATION :: END
|
|||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="play_duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -806,7 +818,7 @@ DOCUMENTATION :: END
|
|||
% elif data['media_type'] == 'album':
|
||||
${data['parent_title']}<br />${data['title']}
|
||||
% elif data['media_type'] == 'track':
|
||||
${data['original_title'] or data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
|
||||
${data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
|
||||
% endif
|
||||
</strong>
|
||||
</p>
|
||||
|
@ -853,7 +865,7 @@ DOCUMENTATION :: END
|
|||
%>
|
||||
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
|
||||
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'):
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track', 'collection', 'playlist'):
|
||||
<script>
|
||||
function loadHistoryTable(transcode_decision) {
|
||||
// Build watch history table
|
||||
|
@ -866,13 +878,16 @@ DOCUMENTATION :: END
|
|||
transcode_decision: transcode_decision,
|
||||
user_id: "${history_user_id}",
|
||||
% if data['live']:
|
||||
guid: "${data['guid']}
|
||||
guid: "${data['guid']}"
|
||||
% elif data['media_type'] in ('show', 'artist'):
|
||||
grandparent_rating_key: "${data['rating_key']}"
|
||||
% elif data['media_type'] in ('season', 'album'):
|
||||
parent_rating_key: "${data['rating_key']}"
|
||||
% elif data['media_type'] in ('movie', 'episode', 'track'):
|
||||
rating_key: "${data['rating_key']}"
|
||||
% elif data['media_type'] in ('collection', 'playlist'):
|
||||
media_type: "${data['media_type']}",
|
||||
rating_key: "${data['rating_key']}"
|
||||
% endif
|
||||
};
|
||||
}
|
||||
|
@ -925,13 +940,20 @@ DOCUMENTATION :: END
|
|||
});
|
||||
</script>
|
||||
% endif
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'):
|
||||
% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track', 'collection', 'playlist'):
|
||||
<script>
|
||||
// Populate watch time stats
|
||||
$.ajax({
|
||||
url: 'item_watch_time_stats',
|
||||
async: true,
|
||||
data: { rating_key: "${data['rating_key']}" },
|
||||
data: {
|
||||
% if data['live']:
|
||||
guid: "${data['guid']}"
|
||||
% else:
|
||||
rating_key: "${data['rating_key']}",
|
||||
media_type: "${data['media_type']}"
|
||||
% endif
|
||||
},
|
||||
complete: function(xhr, status) {
|
||||
$("#watch-time-stats").html(xhr.responseText);
|
||||
}
|
||||
|
@ -940,7 +962,14 @@ DOCUMENTATION :: END
|
|||
$.ajax({
|
||||
url: 'item_user_stats',
|
||||
async: true,
|
||||
data: { rating_key: "${data['rating_key']}" },
|
||||
data: {
|
||||
% if data['live']:
|
||||
guid: "${data['guid']}"
|
||||
% else:
|
||||
rating_key: "${data['rating_key']}",
|
||||
media_type: "${data['media_type']}"
|
||||
% endif
|
||||
},
|
||||
complete: function(xhr, status) {
|
||||
$("#user-stats").html(xhr.responseText);
|
||||
}
|
||||
|
|
|
@ -160,6 +160,16 @@ DOCUMENTATION :: END
|
|||
% endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="item-children-instance-text-wrapper poster-item">
|
||||
<h3>
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
|
||||
</h3>
|
||||
% if media_type == 'collection':
|
||||
<h3 class="text-muted">
|
||||
<a class="text-muted" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>
|
||||
</h3>
|
||||
% endif
|
||||
</div>
|
||||
% elif child['media_type'] == 'episode':
|
||||
<a href="${page('info', child['rating_key'])}" title="Episode ${child['media_index']}">
|
||||
<div class="item-children-poster">
|
||||
|
@ -179,6 +189,29 @@ DOCUMENTATION :: END
|
|||
<h3>
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
|
||||
</h3>
|
||||
% if media_type == 'collection':
|
||||
<h3 class="text-muted">
|
||||
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">${child['grandparent_title']}</a>
|
||||
</h3>
|
||||
<h3 class="text-muted">
|
||||
<a href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${short_season(child['parent_title'])}</a>
|
||||
· <a href="${page('info', child['rating_key'])}" title="Episode ${child['media_index']}">E${child['media_index']}</a>
|
||||
</h3>
|
||||
% endif
|
||||
</div>
|
||||
% elif child['media_type'] == 'artist':
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
|
||||
<div class="item-children-poster">
|
||||
<div class="item-children-poster-face cover-item" style="background-image: url(${page('pms_image_proxy', child['thumb'], child['rating_key'], 300, 300, fallback='cover')});"></div>
|
||||
% if _session['user_group'] == 'admin':
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="item-children-instance-text-wrapper cover-item">
|
||||
<h3>
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
|
||||
</h3>
|
||||
</div>
|
||||
% elif child['media_type'] == 'album':
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
|
||||
|
@ -193,6 +226,11 @@ DOCUMENTATION :: END
|
|||
<h3>
|
||||
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
|
||||
</h3>
|
||||
% if media_type == 'collection':
|
||||
<h3 class="text-muted">
|
||||
<a class="text-muted" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>
|
||||
</h3>
|
||||
% endif
|
||||
</div>
|
||||
% elif child['media_type'] == 'track':
|
||||
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
|
||||
|
@ -205,7 +243,15 @@ DOCUMENTATION :: END
|
|||
${child['title']}
|
||||
</span>
|
||||
</a>
|
||||
% if child['original_title']:
|
||||
% if media_type == 'collection':
|
||||
-
|
||||
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
|
||||
<span class="thumb-tooltip" data-toggle="popover" data-img="${page('pms_image_proxy', child['grandparent_thumb'], child['grandparent_rating_key'], 300, 300, fallback='cover')}" data-height="80" data-width="80">
|
||||
${child['grandparent_title']}
|
||||
</span>
|
||||
</a>
|
||||
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
|
||||
% elif child['original_title']:
|
||||
<span class="text-muted"> - ${child['original_title']}</span>
|
||||
% endif
|
||||
</span>
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
<div id="ip_error" class="col-sm-12 text-muted"></div>
|
||||
<div class="col-sm-6">
|
||||
<ul class="list-unstyled">
|
||||
<li>Continent: <strong><span id="continent"></span></strong></li>
|
||||
<li>Country: <strong><span id="country"></span></strong></li>
|
||||
<li>Region: <strong><span id="region"></span></strong></li>
|
||||
<li>City: <strong><span id="city"></span></strong></li>
|
||||
|
@ -103,6 +104,7 @@
|
|||
$('#ip_error').html('<i class="fa fa-exclamation-circle"></i> ' + result.message).show();
|
||||
} else {
|
||||
var data = result.data;
|
||||
$('#continent').html(data.continent);
|
||||
$('#country').html(data.country);
|
||||
$('#region').html(data.region);
|
||||
$('#city').html(data.city);
|
||||
|
|
9
data/interfaces/default/js/bootstrap-select.min.js
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
var formatter_function = function() {
|
||||
if (moment(this.x, 'X').isValid() && (this.x > 946684800)) {
|
||||
var s = '<b>'+ moment(this.x).format('ddd MMM D') +'</b>';
|
||||
} else {
|
||||
var s = '<b>'+ this.x +'</b>';
|
||||
}
|
||||
$.each(this.points, function(i, point) {
|
||||
s += '<br/>'+point.series.name+': '+point.y;
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
var hc_concurrent_streams_by_stream_type_options = {
|
||||
chart: {
|
||||
type: 'line',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'graph_concurrent_streams_by_stream_type'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
font: '9pt "Open Sans", sans-serif',
|
||||
color: '#A0A0A0'
|
||||
},
|
||||
itemHoverStyle: {
|
||||
color: '#FFF'
|
||||
},
|
||||
itemHiddenStyle: {
|
||||
color: '#444'
|
||||
}
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function() {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
formatter: function() {
|
||||
return moment(this.value).format("MMM D");
|
||||
},
|
||||
style: {
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
categories: [{}],
|
||||
plotBands: []
|
||||
},
|
||||
yAxis: {
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
color: '#aaa'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
crosshairs: true,
|
||||
formatter: formatter_function
|
||||
},
|
||||
series: [{}]
|
||||
};
|
|
@ -288,23 +288,10 @@ function isPrivateIP(ip_address) {
|
|||
}
|
||||
|
||||
function humanTime(seconds) {
|
||||
var d = Math.floor(moment.duration(seconds, 'seconds').asDays());
|
||||
var h = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours());
|
||||
var m = Math.round(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes());
|
||||
|
||||
var text = '';
|
||||
if (d > 0) {
|
||||
text = '<h3>' + d + '</h3><p> day' + ((d > 1) ? 's' : '') + '</p>'
|
||||
+ '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
|
||||
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||
} else if (h > 0) {
|
||||
text = '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
|
||||
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||
} else {
|
||||
text = '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||
if (seconds > 0) {
|
||||
return humanDuration(seconds * 1000).replaceAll(/(\d+) (\w+)/g, '<h3>$1</h3><p>$2</p>')
|
||||
}
|
||||
|
||||
return text
|
||||
return "<h3>0</h3><p>mins</p>";
|
||||
}
|
||||
|
||||
String.prototype.toProperCase = function () {
|
||||
|
@ -360,7 +347,8 @@ function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) {
|
|||
sig = 'dhms'
|
||||
}
|
||||
|
||||
ms = ms * factors[units];
|
||||
r = factors[sig.slice(-1)];
|
||||
ms = Math.round(ms * factors[units] / r) * r;
|
||||
|
||||
h = ms % factors['d'];
|
||||
d = Math.trunc(ms / factors['d']);
|
||||
|
@ -619,7 +607,7 @@ function getPlexHeaders(clientID) {
|
|||
'X-Plex-Platform-Version': p.version,
|
||||
'X-Plex-Model': 'Plex OAuth',
|
||||
'X-Plex-Device': p.os,
|
||||
'X-Plex-Device-Name': p.name,
|
||||
'X-Plex-Device-Name': p.name + ' (Tautulli)',
|
||||
'X-Plex-Device-Screen-Resolution': window.screen.width + 'x' + window.screen.height,
|
||||
'X-Plex-Language': 'en'
|
||||
};
|
||||
|
@ -929,3 +917,50 @@ $('.modal').on('hide.bs.modal', function (e) {
|
|||
$.fn.hasScrollBar = function() {
|
||||
return this.get(0).scrollHeight > this.get(0).clientHeight;
|
||||
}
|
||||
|
||||
function paginateScroller(scrollerId, buttonClass) {
|
||||
$(buttonClass).click(function (e) {
|
||||
e.preventDefault();
|
||||
var scroller = $(scrollerId + "-row-scroller");
|
||||
var scrollerParent = scroller.parent();
|
||||
var containerWidth = scrollerParent.width();
|
||||
var scrollCurrent = scrollerParent.scrollLeft();
|
||||
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
|
||||
var scrollMax = scroller.width() - Math.abs(scrollAmount);
|
||||
var scrollTotal = Math.min(parseInt(scrollCurrent / 175) * 175 + scrollAmount, scrollMax);
|
||||
scrollerParent.animate({ scrollLeft: scrollTotal }, 250);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightScrollerButton(scrollerId) {
|
||||
var scroller = $(scrollerId + "-row-scroller");
|
||||
var scrollerParent = scroller.parent();
|
||||
var buttonLeft = $(scrollerId + "-page-left");
|
||||
var buttonRight = $(scrollerId + "-page-right");
|
||||
|
||||
var numElems = scroller.find("li").length;
|
||||
scroller.width(numElems * 175);
|
||||
$(buttonLeft).addClass("disabled").blur();
|
||||
if (scroller.width() > scrollerParent.width()) {
|
||||
$(buttonRight).removeClass("disabled");
|
||||
} else {
|
||||
$(buttonRight).addClass("disabled");
|
||||
}
|
||||
|
||||
scrollerParent.scroll(function () {
|
||||
var scrollCurrent = $(this).scrollLeft();
|
||||
var scrollMax = scroller.width() - $(this).width();
|
||||
|
||||
if (scrollCurrent == 0) {
|
||||
$(buttonLeft).addClass("disabled").blur();
|
||||
} else {
|
||||
$(buttonLeft).removeClass("disabled");
|
||||
}
|
||||
|
||||
if (scrollCurrent >= scrollMax) {
|
||||
$(buttonRight).addClass("disabled").blur();
|
||||
} else {
|
||||
$(buttonRight).removeClass("disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -32,7 +32,12 @@ collections_table_options = {
|
|||
if (rowData['smart']) {
|
||||
smart = '<span class="media-type-tooltip" data-toggle="tooltip" title="Smart Collection"><i class="fa fa-cog fa-fw"></i></span> '
|
||||
}
|
||||
var thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['ratingKey'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
console.log(rowData['subtype'])
|
||||
if (rowData['subtype'] === 'artist' || rowData['subtype'] === 'album' || rowData['subtype'] === 'track') {
|
||||
var thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['ratingKey'], 300, 300, null, null, null, 'cover') + '" data-height="80" data-width="80">' + rowData['title'] + '</span>';
|
||||
} else {
|
||||
var thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['ratingKey'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
|
||||
}
|
||||
$(td).html(smart + '<a href="' + page('info', rowData['ratingKey']) + '">' + thumb_popover + '</a>');
|
||||
}
|
||||
},
|
||||
|
|
|
@ -100,7 +100,7 @@ export_table_options = {
|
|||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== '') {
|
||||
var images = '';
|
||||
if (rowData['thumb_level'] || rowData['art_level']) {
|
||||
if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level']) {
|
||||
images = ' + images';
|
||||
}
|
||||
$(td).html(cellData + images);
|
||||
|
@ -161,14 +161,14 @@ export_table_options = {
|
|||
if (cellData === 1 && rowData['exists']) {
|
||||
var tooltip_title = '';
|
||||
var icon = '';
|
||||
if (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) {
|
||||
tooltip_title = 'Zip Archive';
|
||||
if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) {
|
||||
tooltip_title = 'ZIP Archive';
|
||||
icon = 'fa-file-archive';
|
||||
} else {
|
||||
tooltip_title = rowData['file_format'].toUpperCase() + ' File';
|
||||
icon = 'fa-file-download';
|
||||
}
|
||||
var icon = (rowData['thumb_level'] || rowData['art_level'] || rowData['individual_files']) ? 'fa-file-archive' : 'fa-file-download';
|
||||
var icon = (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) ? 'fa-file-archive' : 'fa-file-download';
|
||||
$(td).html('<button class="btn btn-xs btn-success pull-left" data-id="' + rowData['export_id'] + '"><span data-toggle="tooltip" data-placement="left" title="' + tooltip_title + '"><i class="fa ' + icon + ' fa-fw"></i> Download</span></button>');
|
||||
} else if (cellData === 0) {
|
||||
var percent = Math.min(getPercent(rowData['exported_items'], rowData['total_items']), 99)
|
||||
|
|
|
@ -247,7 +247,7 @@ history_table_options = {
|
|||
},
|
||||
{
|
||||
"targets": [11],
|
||||
"data": "duration",
|
||||
"data": "play_duration",
|
||||
"render": function (data, type, full) {
|
||||
if (data !== null) {
|
||||
return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins';
|
||||
|
@ -263,13 +263,17 @@ history_table_options = {
|
|||
"targets": [12],
|
||||
"data": "watched_status",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
var circleValue = "";
|
||||
if (cellData == 1) {
|
||||
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-circle"></i></span>');
|
||||
circleValue = " circle-full";
|
||||
} else if (cellData == 0.75) {
|
||||
circleValue = " circle-three-quarter";
|
||||
} else if (cellData == 0.5) {
|
||||
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-adjust fa-rotate-180"></i></span>');
|
||||
} else {
|
||||
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><i class="fa fa-lg fa-circle-o"></i></span>');
|
||||
circleValue = " circle-half";
|
||||
} else if (cellData == 0.25) {
|
||||
circleValue = " circle-quarter";
|
||||
}
|
||||
$(td).html('<span class="watched-tooltip" data-toggle="tooltip" title="' + rowData['percent_complete'] + '%"><div class="circle' + circleValue + '" /></span>');
|
||||
},
|
||||
"searchable": false,
|
||||
"orderable": false,
|
||||
|
@ -529,7 +533,7 @@ function childTableFormat(rowData) {
|
|||
'<th align="left" id="started">Started</th>' +
|
||||
'<th align="left" id="paused_counter">Paused</th>' +
|
||||
'<th align="left" id="stopped">Stopped</th>' +
|
||||
'<th align="left" id="duration">Duration</th>' +
|
||||
'<th align="left" id="play_duration">Duration</th>' +
|
||||
'<th align="left" id="percent_complete"></th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
|
|
|
@ -33,7 +33,7 @@ libraries_list_table_options = {
|
|||
$(td).html('<div class="edit-library-toggles">' +
|
||||
'<button class="btn btn-xs btn-warning delete-library" data-id="' + rowData['row_id'] + '" data-toggle="button"><i class="fa fa-trash-o fa-fw"></i> Delete</button> ' +
|
||||
'<button class="btn btn-xs btn-warning purge-library" data-id="' + rowData['row_id'] + '" data-toggle="button"><i class="fa fa-eraser fa-fw"></i> Purge</button>   ' +
|
||||
'<input type="checkbox" id="keep_history-' + rowData['section_id'] + '" name="keep_history" value="1" ' + rowData['keep_history'] + '><label class="edit-tooltip" for="keep_history-' + rowData['section_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label> ' +
|
||||
'<input type="checkbox" id="keep_history-' + rowData['section_id'] + '" name="keep_history" value="1" ' + (rowData['keep_history'] ? 'checked' : '') + '><label class="edit-tooltip" for="keep_history-' + rowData['section_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label> ' +
|
||||
'</div>');
|
||||
},
|
||||
"width": "7%",
|
||||
|
|
|
@ -50,8 +50,8 @@ users_list_table_options = {
|
|||
$(td).html('<div class="edit-user-toggles">' +
|
||||
'<button class="btn btn-xs btn-warning delete-user" data-id="' + rowData['row_id'] + '" data-toggle="button"><i class="fa fa-trash-o fa-fw"></i> Delete</button> ' +
|
||||
'<button class="btn btn-xs btn-warning purge-user" data-id="' + rowData['row_id'] + '" data-toggle="button"><i class="fa fa-eraser fa-fw"></i> Purge</button>   ' +
|
||||
'<input type="checkbox" id="keep_history-' + rowData['user_id'] + '" name="keep_history" value="1" ' + rowData['keep_history'] + '><label class="edit-tooltip" for="keep_history-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label> ' +
|
||||
'<input type="checkbox" id="allow_guest-' + rowData['user_id'] + '" name="allow_guest" value="1" ' + rowData['allow_guest'] + '><label class="edit-tooltip" for="allow_guest-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle Guest Access"><i class="fa fa-unlock-alt fa-lg fa-fw"></i></label> ' +
|
||||
'<input type="checkbox" id="keep_history-' + rowData['user_id'] + '" name="keep_history" value="1" ' + (rowData['keep_history'] ? 'checked' : '') + '><label class="edit-tooltip" for="keep_history-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label> ' +
|
||||
'<input type="checkbox" id="allow_guest-' + rowData['user_id'] + '" name="allow_guest" value="1" ' + (rowData['allow_guest'] ? 'checked' : '') + '><label class="edit-tooltip" for="allow_guest-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle Guest Access"><i class="fa fa-unlock-alt fa-lg fa-fw"></i></label> ' +
|
||||
'</div>');
|
||||
},
|
||||
"width": "7%",
|
||||
|
@ -90,6 +90,42 @@ users_list_table_options = {
|
|||
},
|
||||
{
|
||||
"targets": [3],
|
||||
"data": "username",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"visible": false,
|
||||
"width": "10%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [4],
|
||||
"data": "title",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"visible": false,
|
||||
"width": "10%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [5],
|
||||
"data": "email",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
$(td).html(cellData);
|
||||
}
|
||||
},
|
||||
"visible": false,
|
||||
"width": "10%",
|
||||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [6],
|
||||
"data": "last_seen",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
@ -103,7 +139,7 @@ users_list_table_options = {
|
|||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [4],
|
||||
"targets": [7],
|
||||
"data": "ip_address",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData) {
|
||||
|
@ -121,7 +157,7 @@ users_list_table_options = {
|
|||
"className": "no-wrap modal-control-ip"
|
||||
},
|
||||
{
|
||||
"targets": [5],
|
||||
"targets": [8],
|
||||
"data": "platform",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
@ -134,7 +170,7 @@ users_list_table_options = {
|
|||
"className": "no-wrap modal-control"
|
||||
},
|
||||
{
|
||||
"targets": [6],
|
||||
"targets": [9],
|
||||
"data":"player",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
@ -155,7 +191,7 @@ users_list_table_options = {
|
|||
"className": "no-wrap modal-control"
|
||||
},
|
||||
{
|
||||
"targets": [7],
|
||||
"targets": [10],
|
||||
"data":"last_played",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
@ -196,7 +232,7 @@ users_list_table_options = {
|
|||
"className": "datatable-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [8],
|
||||
"targets": [11],
|
||||
"data": "plays",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
@ -208,7 +244,7 @@ users_list_table_options = {
|
|||
"className": "no-wrap"
|
||||
},
|
||||
{
|
||||
"targets": [9],
|
||||
"targets": [12],
|
||||
"data": "duration",
|
||||
"createdCell": function (td, cellData, rowData, row, col) {
|
||||
if (cellData !== null && cellData !== '') {
|
||||
|
|
|
@ -149,10 +149,10 @@ DOCUMENTATION :: END
|
|||
<div class="table-card-header">
|
||||
<ul class="nav nav-header nav-dashboard pull-right">
|
||||
<li>
|
||||
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="header-bar">
|
||||
|
@ -175,10 +175,10 @@ DOCUMENTATION :: END
|
|||
<div class="table-card-header">
|
||||
<ul class="nav nav-header nav-dashboard pull-right">
|
||||
<li>
|
||||
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
<a href="#" id="recently-added-page-left" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
<a href="#" id="recently-added-page-right" class="paginate-added btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="header-bar">
|
||||
|
@ -217,13 +217,13 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="transcode_decision-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_play" value="direct play" autocomplete="off"><i class="fa fa-play-circle"></i> Direct Play
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_stream" value="copy" autocomplete="off"><i class="fa fa-stream"></i> Direct Stream
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-transcode" value="transcode" autocomplete="off"><i class="fa fa-server"></i> Transcode
|
||||
</label>
|
||||
</div>
|
||||
|
@ -248,7 +248,7 @@ DOCUMENTATION :: END
|
|||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="play_duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -690,7 +690,8 @@ DOCUMENTATION :: END
|
|||
},
|
||||
complete: function(xhr, status) {
|
||||
$("#library-recently-watched").html(xhr.responseText);
|
||||
highlightWatchedScrollerButton();
|
||||
highlightScrollerButton("#recently-watched");
|
||||
paginateScroller("#recently-watched", ".paginate-watched");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -706,7 +707,8 @@ DOCUMENTATION :: END
|
|||
},
|
||||
complete: function(xhr, status) {
|
||||
$("#library-recently-added").html(xhr.responseText);
|
||||
highlightAddedScrollerButton();
|
||||
highlightScrollerButton("#recently-added");
|
||||
paginateScroller("#recently-added", ".paginate-added");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -716,83 +718,8 @@ DOCUMENTATION :: END
|
|||
recentlyAdded();
|
||||
% endif
|
||||
|
||||
function highlightWatchedScrollerButton() {
|
||||
var scroller = $("#recently-watched-row-scroller");
|
||||
var numElems = scroller.find("li").length;
|
||||
scroller.width(numElems * 175);
|
||||
if (scroller.width() > $("#library-recently-watched").width()) {
|
||||
$("#recently-watched-page-right").removeClass("disabled");
|
||||
} else {
|
||||
$("#recently-watched-page-right").addClass("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
function highlightAddedScrollerButton() {
|
||||
var scroller = $("#recently-added-row-scroller");
|
||||
var numElems = scroller.find("li").length;
|
||||
scroller.width(numElems * 175);
|
||||
if (scroller.width() > $("#library-recently-added").width()) {
|
||||
$("#recently-added-page-right").removeClass("disabled");
|
||||
} else {
|
||||
$("#recently-added-page-right").addClass("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
$(window).resize(function() {
|
||||
highlightWatchedScrollerButton();
|
||||
highlightAddedScrollerButton();
|
||||
});
|
||||
|
||||
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
|
||||
|
||||
var leftTotalWatched = 0;
|
||||
$(".paginate-watched").click(function (e) {
|
||||
e.preventDefault();
|
||||
var scroller = $("#recently-watched-row-scroller");
|
||||
var containerWidth = $("#library-recently-watched").width();
|
||||
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
|
||||
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
|
||||
|
||||
leftTotalWatched = Math.max(Math.min(leftTotalWatched + scrollAmount, 0), leftMax);
|
||||
scroller.animate({ left: leftTotalWatched }, 250);
|
||||
|
||||
if (leftTotalWatched == 0) {
|
||||
$("#recently-watched-page-left").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-watched-page-left").removeClass("disabled");
|
||||
}
|
||||
|
||||
if (leftTotalWatched == leftMax) {
|
||||
$("#recently-watched-page-right").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-watched-page-right").removeClass("disabled");
|
||||
}
|
||||
});
|
||||
|
||||
var leftTotalAdded = 0;
|
||||
$(".paginate-added").click(function (e) {
|
||||
e.preventDefault();
|
||||
var scroller = $("#recently-added-row-scroller");
|
||||
var containerWidth = $("#library-recently-added").width();
|
||||
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
|
||||
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
|
||||
|
||||
leftTotalAdded = Math.max(Math.min(leftTotalAdded + scrollAmount, 0), leftMax);
|
||||
scroller.animate({ left: leftTotalAdded }, 250);
|
||||
|
||||
if (leftTotalAdded == 0) {
|
||||
$("#recently-added-page-left").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-added-page-left").removeClass("disabled");
|
||||
}
|
||||
|
||||
if (leftTotalAdded == leftMax) {
|
||||
$("#recently-added-page-right").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-added-page-right").removeClass("disabled");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
// Javascript to enable link to tab
|
||||
|
|
|
@ -36,7 +36,7 @@ DOCUMENTATION :: END
|
|||
|
||||
%>
|
||||
<div class="dashboard-recent-media-row">
|
||||
<div id="recently-added-row-scroller" style="left: 0;">
|
||||
<div id="recently-added-row-scroller">
|
||||
<ul class="dashboard-recent-media list-unstyled">
|
||||
% for item in data:
|
||||
<li>
|
||||
|
|
|
@ -40,7 +40,7 @@ DOCUMENTATION :: END
|
|||
%>
|
||||
<div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}">
|
||||
<div class="dashboard-stats-container">
|
||||
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'], None, 500, 280, 40, '282828', 3, fallback='art')});">
|
||||
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(${page('pms_image_proxy', row0['art'] or row0['library_art'], None, 500, 280, 40, '282828', 3, fallback=row0['library_art'])});" data-library_art="${row0['library_art']}">
|
||||
% if row0['thumb'].startswith('http'):
|
||||
<div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat hidden-xs" style="background-image: url(${page('pms_image_proxy', row0['thumb'], None, 80, 80)});"></div>
|
||||
% else:
|
||||
|
@ -56,7 +56,7 @@ DOCUMENTATION :: END
|
|||
<ul class="list-unstyled dashboard-stats-info-list">
|
||||
% for section in data[section_type]:
|
||||
<li class="dashboard-stats-info-item ${'expanded' if loop.index == 0 else ''}" data-stat_id="${section_type}"
|
||||
data-art="${section.get('art')}" data-thumb="${section.get('thumb')}">
|
||||
data-art="${section.get('art')}" data-thumb="${section.get('thumb')}" data-library_art="${section.get('library_art')}">
|
||||
<div class="sub-list">${loop.index + 1}</div>
|
||||
<div class="sub-value">
|
||||
<a href="${page('library', section['section_id'])}" title="${section['section_name']}">
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
|
||||
|
@ -24,7 +25,7 @@
|
|||
|
||||
<!-- ICONS -->
|
||||
<!-- Android -->
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.9.0" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="#282a2d">
|
||||
<!-- Apple -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
|
||||
|
|
|
@ -453,12 +453,12 @@
|
|||
|
||||
$("#download-tautullilog").click(function () {
|
||||
var logfile = $(".tab-pane.active").data('logfile');
|
||||
window.location.href = "download_log?logfile=" + logfile;
|
||||
window.location.href = "download_log?logfile=" + window.encodeURIComponent(logfile);
|
||||
});
|
||||
|
||||
$("#download-plexserverlog").click(function () {
|
||||
var logfile = $("option:selected", "#plex-log-files").val();
|
||||
window.location.href = "download_plex_log?logfile=" + logfile;
|
||||
window.location.href = "download_plex_log?logfile=" + window.encodeURIComponent(logfile);
|
||||
});
|
||||
|
||||
$("#clear-notify-logs").click(function () {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="mobile-device-config-modal-header">${device['device_name']} Settings <small><span class="device_id">(Device ID: ${device['id']})</span></small></h4>
|
||||
<h4 class="modal-title" id="mobile-device-config-modal-header">${device['device_name']} Settings <small><span class="device_id">(Device ID: ${device['id']}${' - ' + device['friendly_name'] if device['friendly_name'] else ''})</span></small></h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
|
|
|
@ -28,15 +28,17 @@ DOCUMENTATION :: END
|
|||
<span class="toggle-left official-tooltip" data-toggle="tooltip" data-placement="top" title="Unofficial or Unknown App"><i class="fa fa-lg fa-fw fa-exclamation-triangle"></i></span>
|
||||
% endif
|
||||
${device['friendly_name'] or device['device_name']} <span class="friendly_name">(${device['id']})</span>
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
|
||||
<span class="toggle-right friendly_name">
|
||||
% if device['last_seen']:
|
||||
<script>
|
||||
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
||||
</script>
|
||||
<span id="device-last_seen-${device['id']}">
|
||||
<script>
|
||||
$("#device-last_seen-${device['id']}").text(moment("${device['last_seen']}", "X").fromNow())
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
never
|
||||
% endif
|
||||
<i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
@ -58,7 +60,7 @@ DOCUMENTATION :: END
|
|||
|
||||
getPlexPyURL = function () {
|
||||
var deferred = $.Deferred();
|
||||
if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
|
||||
if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1" && location.hostname !== "[::1]") {
|
||||
deferred.resolve(location.href.split('/settings')[0]);
|
||||
} else {
|
||||
$.get('get_plexpy_url').then(function (url) {
|
||||
|
@ -70,11 +72,11 @@ DOCUMENTATION :: END
|
|||
|
||||
function checkQRAddress(url) {
|
||||
var parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
parser.setAttribute('href', url);
|
||||
var hostname = parser.hostname;
|
||||
var protocol = parser.protocol;
|
||||
|
||||
if (hostname === '127.0.0.1' || hostname === 'localhost') {
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
|
||||
$('#api_qr_localhost').toggle(true);
|
||||
$('#api_qr_private').toggle(false);
|
||||
} else {
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings <small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']})</span></small></h4>
|
||||
<h4 class="modal-title" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings <small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']}${' - ' + newsletter['friendly_name'] if newsletter['friendly_name'] else ''})</span></small></h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
|
@ -32,7 +32,7 @@
|
|||
<div class="col-md-12">
|
||||
<div class="checkbox" style="margin-bottom: 20px;">
|
||||
<label>
|
||||
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" ${checked(newsletter['active'])}> Enable the Newsletter
|
||||
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['active'])}> Enable the Newsletter
|
||||
</label>
|
||||
<input type="hidden" id="active_value" name="active" value="${newsletter['active']}">
|
||||
</div>
|
||||
|
@ -40,27 +40,31 @@
|
|||
<label for="custom_cron">Schedule</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron">
|
||||
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron" autocomplete="off">
|
||||
<option value="0" ${'selected' if newsletter['config']['custom_cron'] == 0 else ''}>Simple</option>
|
||||
<option value="1" ${'selected' if newsletter['config']['custom_cron'] == 1 else ''}>Custom</option>
|
||||
</select>
|
||||
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" />
|
||||
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" autocomplete="off" />
|
||||
<div id="cron-widget"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
|
||||
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>. Only standard cron values are valid.</span>
|
||||
<span id="custom_cron_message">
|
||||
Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank" rel="noreferrer">custom crontab</a>.
|
||||
<a href="${anon_url('https://apscheduler.readthedocs.io/en/3.x/modules/triggers/cron.html#expression-types')}" target="_blank" rel="noreferrer">Click here</a> for a list of supported expressions.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="time_frame">Time Frame</label>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group newsletter-time_frame">
|
||||
<span class="input-group-addon form-control btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}">
|
||||
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units">
|
||||
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}" autocomplete="off">
|
||||
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units" autocomplete="off">
|
||||
<option value="months" ${'selected' if newsletter['config']['time_frame_units'] == 'months' else ''}>months</option>
|
||||
<option value="days" ${'selected' if newsletter['config']['time_frame_units'] == 'days' else ''}>days</option>
|
||||
<option value="hours" ${'selected' if newsletter['config']['time_frame_units'] == 'hours' else ''}>hours</option>
|
||||
</select>
|
||||
|
@ -84,7 +88,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
|
@ -94,7 +98,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
|
@ -112,7 +116,7 @@
|
|||
% elif item['input_type'] == 'checkbox':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
|
||||
</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
|
@ -122,7 +126,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
% for key, value in sorted(item['select_options'].items()):
|
||||
% if key == item['value']:
|
||||
<option value="${key}" selected>${value}</option>
|
||||
|
@ -140,9 +144,11 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
% if item['select_all']:
|
||||
<option value="select-all">Select All</option>
|
||||
<option value="remove-all">Remove All</option>
|
||||
% endif
|
||||
% if isinstance(item['select_options'], dict):
|
||||
% for section, options in item['select_options'].items():
|
||||
<optgroup label="${section}">
|
||||
|
@ -152,7 +158,9 @@
|
|||
</optgroup>
|
||||
% endfor
|
||||
% else:
|
||||
% if item['select_all']:
|
||||
<option value="border-all"></option>
|
||||
% endif
|
||||
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
|
@ -170,7 +178,7 @@
|
|||
<label for="id_name">Unique ID Name</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
|
||||
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
|
@ -183,7 +191,7 @@
|
|||
<label for="friendly_name">Description</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30">
|
||||
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter a description to help identify this newsletter in the newsletters list.</p>
|
||||
|
@ -197,7 +205,7 @@
|
|||
<label>Saving</label>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="newsletter_config_save_only_checkbox" data-id="newsletter_config_save_only" class="checkboxes" value="1" ${checked(newsletter['config']['save_only'])}> Save HTML File Only
|
||||
<input type="checkbox" id="newsletter_config_save_only_checkbox" data-id="newsletter_config_save_only" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['save_only'])}> Save HTML File Only
|
||||
</label>
|
||||
<p class="help-block">Enable to save the newsletter HTML file without sending it to any notification agent.</p>
|
||||
<input type="hidden" id="newsletter_config_save_only" name="newsletter_config_save_only" value="${newsletter['config']['save_only']}">
|
||||
|
@ -206,7 +214,7 @@
|
|||
<label for="newsletter_config_filename">HTML File Name</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="newsletter_config_filename" name="newsletter_config_filename" value="${newsletter['config']['filename']}" size="30">
|
||||
<input type="text" class="form-control" id="newsletter_config_filename" name="newsletter_config_filename" value="${newsletter['config']['filename']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter the file name to use when saving the newsletter (ending with <span class="inline-pre">.html</span>). You may use any of the <a href="#newsletter-text-sub-modal" data-toggle="modal">newsletter text parameters</a>. Leave blank for default.</p>
|
||||
|
@ -216,7 +224,7 @@
|
|||
<label>Sending</label>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" ${checked(newsletter['config']['formatted'])}> Send Newsletter as an HTML Formatted Email
|
||||
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['formatted'])}> Send Newsletter as an HTML Formatted Email
|
||||
</label>
|
||||
<p class="help-block">Enable to send the newsletter as an HTML formatted Email. Disable to only send a subject and body message to a different notification agent.</p>
|
||||
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
|
||||
|
@ -224,7 +232,7 @@
|
|||
<div class="form-group" id="email_notifier_select">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
|
||||
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" autocomplete="off" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
|
||||
</label>
|
||||
<p class="help-block">Enable to group this newsletter together in a single Email thread. Disable to send a new Email for each newsletter.</p>
|
||||
<input type="hidden" id="newsletter_config_threaded" name="newsletter_config_threaded" value="${newsletter['config']['threaded']}">
|
||||
|
@ -232,7 +240,7 @@
|
|||
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id">
|
||||
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id" autocomplete="off">
|
||||
% for notifier in email_notifiers:
|
||||
<% selected = 'selected' if notifier['id'] == newsletter['email_config']['notifier_id'] else '' %>
|
||||
% if notifier['friendly_name']:
|
||||
|
@ -255,7 +263,7 @@
|
|||
<label for="newsletter_config_notifier_id">Notification Agent</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id">
|
||||
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id" autocomplete="off">
|
||||
% for notifier in other_notifiers:
|
||||
<% selected = 'selected' if notifier['id'] == newsletter['config']['notifier_id'] else '' %>
|
||||
% if notifier['friendly_name']:
|
||||
|
@ -286,7 +294,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
|
@ -296,7 +304,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
|
@ -314,7 +322,7 @@
|
|||
% elif item['input_type'] == 'checkbox' and item['name'] != 'newsletter_email_html_support':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
|
||||
</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
|
@ -324,7 +332,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
% for key, value in sorted(item['select_options'].items()):
|
||||
% if isinstance(value, list):
|
||||
<optgroup label="${key}">
|
||||
|
@ -346,7 +354,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
<option value="select-all">Select All</option>
|
||||
<option value="remove-all">Remove All</option>
|
||||
% if isinstance(item['select_options'], dict):
|
||||
|
@ -391,7 +399,7 @@
|
|||
<label for="subject">Subject</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30">
|
||||
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
|
@ -402,7 +410,7 @@
|
|||
<label for="body">Body</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="body" name="body" data-autoresize>${newsletter['body']}</textarea>
|
||||
<textarea class="form-control" id="body" name="body" autocomplete="off" data-autoresize>${newsletter['body']}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
|
@ -413,7 +421,7 @@
|
|||
<label for="message">Message</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="message" name="message" data-autoresize>${newsletter['message']}</textarea>
|
||||
<textarea class="form-control" id="message" name="message" autocomplete="off" data-autoresize>${newsletter['message']}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
|
@ -476,7 +484,7 @@
|
|||
});
|
||||
|
||||
if (${newsletter['config']['custom_cron']}) {
|
||||
$('#cron_value').val('${newsletter['cron']}');
|
||||
$('#cron_value').val('${newsletter['cron'] | n}');
|
||||
} else {
|
||||
try {
|
||||
cron_widget.cron('value', '${newsletter['cron']}');
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<%
|
||||
from six.moves.urllib.parse import urlencode
|
||||
from urllib.parse import urlencode
|
||||
%>
|
||||
<!doctype html>
|
||||
|
||||
|
|
|
@ -20,13 +20,28 @@ DOCUMENTATION :: END
|
|||
% else:
|
||||
${newsletter['agent_label']} <span class="friendly_name">(${newsletter['id']})</span>
|
||||
% endif
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
|
||||
<span class="toggle-right friendly_name">
|
||||
% if newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
|
||||
<% job = newsletter_handler.NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
|
||||
<script>
|
||||
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
|
||||
</script>
|
||||
<span id="newsletter-next_run-${newsletter['id']}">
|
||||
<script>
|
||||
$("#newsletter-next_run-${newsletter['id']}").text(
|
||||
"Next: " + moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow() + " | ")
|
||||
</script>
|
||||
</span>
|
||||
% endif
|
||||
% if newsletter['last_triggered']:
|
||||
<% icon, icon_tooltip = ('fa-check', 'Success') if newsletter['last_success'] else ('fa-times', 'Failed') %>
|
||||
<span id="newsletter-last_triggered-${newsletter['id']}">
|
||||
<script>
|
||||
$("#newsletter-last_triggered-${newsletter['id']}").html(
|
||||
"Last: " + moment("${newsletter['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
|
||||
)
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
Last: never
|
||||
<i class="fa fa-lg fa-fw fa-minus"></i>
|
||||
% endif
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title" id="notifier-config-modal-header">${notifier['agent_label']} Settings <small><span class="notifier_id">(Notifier ID: ${notifier['id']})</span></small></h4>
|
||||
<h4 class="modal-title" id="notifier-config-modal-header">${notifier['agent_label']} Settings <small><span class="notifier_id">(Notifier ID: ${notifier['id']}${' - ' + notifier['friendly_name'] if notifier['friendly_name'] else ''})</span></small></h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
|
@ -51,13 +51,13 @@
|
|||
<div class="col-md-12">
|
||||
% if notifier['agent_name'] == 'scripts' and item['name'] == 'scripts_script_folder':
|
||||
<div class="input-group">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-form" type="button" id="${item['name']}_browse" data-toggle="browse" data-filter=".folderonly" data-target="#${item['name']}">Browse</button>
|
||||
</span>
|
||||
</div>
|
||||
% else:
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
|
@ -72,7 +72,7 @@
|
|||
<span class="input-group-btn">
|
||||
<button class="btn btn-form reveal-token" type="button"><i class="fa fa-eye-slash"></i></button>
|
||||
</span>
|
||||
<input type="password" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
|
||||
<input type="password" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off" ${'readonly' if item.get('readonly') else ''}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -83,7 +83,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
|
||||
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
|
@ -101,7 +101,7 @@
|
|||
% elif item['input_type'] == 'checkbox':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
|
||||
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(item['value'])}> ${item['label']}
|
||||
</label>
|
||||
<p class="help-block">${item['description'] | n}</p>
|
||||
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
|
||||
|
@ -111,7 +111,7 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
% for key, value in sorted(item['select_options'].items()):
|
||||
% if isinstance(value, list):
|
||||
<optgroup label="${key}">
|
||||
|
@ -133,9 +133,11 @@
|
|||
<label for="${item['name']}">${item['label']}</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}">
|
||||
<select class="form-control" id="${item['name']}" name="${item['name']}" autocomplete="off">
|
||||
% if item['select_all']:
|
||||
<option value="select-all">Select All</option>
|
||||
<option value="remove-all">Remove All</option>
|
||||
% endif
|
||||
% if isinstance(item['select_options'], dict):
|
||||
% for section, options in item['select_options'].items():
|
||||
<optgroup label="${section}">
|
||||
|
@ -145,7 +147,9 @@
|
|||
</optgroup>
|
||||
% endfor
|
||||
% else:
|
||||
% if item['select_all']:
|
||||
<option value="border-all"></option>
|
||||
% endif
|
||||
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
|
||||
<option value="${option['value']}">${option['text']}</option>
|
||||
% endfor
|
||||
|
@ -163,7 +167,7 @@
|
|||
<label for="friendly_name">Description</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30">
|
||||
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter a description to help identify this agent in the notification agents list.</p>
|
||||
|
@ -181,7 +185,7 @@
|
|||
% for action in available_notification_actions:
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${checked(notifier['actions'][action['name']])}> ${action['label']}
|
||||
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" autocomplete="off" ${checked(notifier['actions'][action['name']])}> ${action['label']}
|
||||
</label>
|
||||
<p class="help-block">${action['description'] | n}</p>
|
||||
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
|
||||
|
@ -204,7 +208,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label for="custom_conditions_logic">Condition Logic</label>
|
||||
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" />
|
||||
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" autocomplete="off" />
|
||||
<div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div>
|
||||
<p class="help-block">
|
||||
Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
|
||||
|
@ -250,7 +254,7 @@
|
|||
<li>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_subject">Script Arguments</label>
|
||||
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" data-parsley-trigger="change" required>
|
||||
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" autocomplete="off" data-parsley-trigger="change" required>
|
||||
<p class="help-block">Set custom arguments passed to the script.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -276,12 +280,12 @@
|
|||
<li>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_subject">JSON Headers</label>
|
||||
<textarea class="form-control" id="${action['name']}_subject" name="${action['name']}_subject" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['subject']}</textarea>
|
||||
<textarea class="form-control" id="${action['name']}_subject" name="${action['name']}_subject" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['subject']}</textarea>
|
||||
<p class="help-block">Set custom JSON headers.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_body">JSON Data</label>
|
||||
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
|
||||
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
|
||||
<p class="help-block">Set custom JSON data.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -307,12 +311,12 @@
|
|||
<li>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_subject">Subject Line</label>
|
||||
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" data-parsley-trigger="change" required>
|
||||
<input class="form-control" type="text" id="${action['name']}_subject" name="${action['name']}_subject" value="${notifier['notify_text'][action['name']]['subject']}" autocomplete="off" data-parsley-trigger="change" required>
|
||||
<p class="help-block">Set a custom subject line.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_body">Message Body</label>
|
||||
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
|
||||
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" autocomplete="off" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
|
||||
<p class="help-block">Set a custom body.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -343,7 +347,7 @@
|
|||
<label for="test_script">Script</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<select class="form-control" id="test_script" name="test_script">
|
||||
<select class="form-control" id="test_script" name="test_script" autocomplete="off">
|
||||
% for key, value in sorted(notifier['config_options'][2]['select_options'].items()):
|
||||
<option value="${key}">${value}</option>
|
||||
% endfor
|
||||
|
@ -356,7 +360,7 @@
|
|||
<label for="test_script_args">Script Arguments</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input class="form-control" type="text" id="test_script_args" name="test_script_args" value="">
|
||||
<input class="form-control" type="text" id="test_script_args" name="test_script_args" value="" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set custom arguments passed to the script.</p>
|
||||
|
@ -366,7 +370,7 @@
|
|||
<label for="test_subject">JSON Headers</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="test_subject" name="test_subject" data-autoresize></textarea>
|
||||
<textarea class="form-control" id="test_subject" name="test_subject" autocomplete="off" data-autoresize></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set custom JSON headers sent to the webhook.</p>
|
||||
|
@ -375,7 +379,7 @@
|
|||
<label for="test_body">JSON Data</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="test_body" name="test_body" data-autoresize></textarea>
|
||||
<textarea class="form-control" id="test_body" name="test_body" autocomplete="off" data-autoresize></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set custom JSON data sent to the webhook.</p>
|
||||
|
@ -385,7 +389,7 @@
|
|||
<label for="test_subject">Subject Line</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input class="form-control" type="text" id="test_subject" name="test_subject" value="Tautulli">
|
||||
<input class="form-control" type="text" id="test_subject" name="test_subject" value="Tautulli" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set a custom subject line.</p>
|
||||
|
@ -394,7 +398,7 @@
|
|||
<label for="test_body">Message Body</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="test_body" name="test_body" data-autoresize>Test Notification</textarea>
|
||||
<textarea class="form-control" id="test_body" name="test_body" autocomplete="off" data-autoresize>Test Notification</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set a custom body.</p>
|
||||
|
@ -718,6 +722,12 @@
|
|||
$('#pushover_priority').change( function () {
|
||||
pushoverPriority();
|
||||
});
|
||||
|
||||
var $pushover_sound = $('#pushover_sound').selectize({
|
||||
create: true
|
||||
});
|
||||
var pushover_sound = $pushover_sound[0].selectize;
|
||||
pushover_sound.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'pushover_sound'), [])) | n});
|
||||
|
||||
% elif notifier['agent_name'] == 'plexmobileapp':
|
||||
var $plexmobileapp_user_ids = $('#plexmobileapp_user_ids').selectize({
|
||||
|
|
|
@ -19,7 +19,20 @@ DOCUMENTATION :: END
|
|||
% else:
|
||||
${notifier['agent_label']} <span class="friendly_name">(${notifier['id']})</span>
|
||||
% endif
|
||||
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
|
||||
<span class="toggle-right friendly_name">
|
||||
% if notifier['last_triggered']:
|
||||
<% icon, icon_tooltip = ('fa-check', 'Success') if notifier['last_success'] else ('fa-times', 'Failed') %>
|
||||
<span id="notifier-last_triggered-${notifier['id']}">
|
||||
<script>
|
||||
$("#notifier-last_triggered-${notifier['id']}").html(
|
||||
moment("${notifier['last_triggered']}", "X").fromNow() + ' <i class="fa fa-lg fa-fw ${icon}" data-toggle="tooltip" data-placement="top" title="${icon_tooltip}"></i>'
|
||||
)
|
||||
</script>
|
||||
</span>
|
||||
% else:
|
||||
never
|
||||
<i class="fa fa-lg fa-fw fa-minus"></i>
|
||||
% endif
|
||||
</span>
|
||||
</li>
|
||||
% endfor
|
||||
|
|
|
@ -36,7 +36,7 @@ DOCUMENTATION :: END
|
|||
%>
|
||||
% if data:
|
||||
<div class="dashboard-recent-media-row">
|
||||
<div id="recently-added-row-scroller" style="left: 0;">
|
||||
<div id="recently-added-row-scroller">
|
||||
<ul class="dashboard-recent-media list-unstyled">
|
||||
% for item in data:
|
||||
<div class="dashboard-recent-media-instance">
|
||||
|
|
|
@ -13,8 +13,6 @@ DOCUMENTATION :: END
|
|||
import datetime
|
||||
import plexpy
|
||||
from plexpy import common, helpers
|
||||
|
||||
scheduled_jobs = [j.id for j in plexpy.SCHED.get_jobs()]
|
||||
%>
|
||||
|
||||
<table class="config-scheduler-table small-muted">
|
||||
|
@ -29,16 +27,15 @@ DOCUMENTATION :: END
|
|||
</thead>
|
||||
<tbody>
|
||||
% for job, job_type in common.SCHEDULER_LIST.items():
|
||||
% if job in scheduled_jobs:
|
||||
<%
|
||||
sched_job = plexpy.SCHED.get_job(job)
|
||||
now = datetime.datetime.now(sched_job.next_run_time.tzinfo)
|
||||
%>
|
||||
% if sched_job:
|
||||
<tr>
|
||||
<td>${sched_job.id}</td>
|
||||
<td><i class="fa fa-sm fa-fw fa-check"></i> Active</td>
|
||||
<td>${helpers.format_timedelta_Hms(sched_job.trigger.interval)}</td>
|
||||
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - now)}</td>
|
||||
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - datetime.datetime.now(sched_job.next_run_time.tzinfo))}</td>
|
||||
<td>${sched_job.next_run_time.astimezone(plexpy.SYS_TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')}</td>
|
||||
</tr>
|
||||
% elif job_type == 'websocket' and plexpy.WS_CONNECTED:
|
||||
|
|
|
@ -132,12 +132,6 @@
|
|||
</label>
|
||||
<p class="help-block">Change the "<em>Play by day of week</em>" graph to start on Monday. Default is start on Sunday.</p>
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
|
||||
</label>
|
||||
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" id="history_table_activity" name="history_table_activity" value="1" ${config['history_table_activity']}> Current Activity in History Tables
|
||||
|
@ -164,7 +158,7 @@
|
|||
<input type="checkbox" id="log_blacklist_usernames" name="log_blacklist_usernames" value="1" ${config['log_blacklist_usernames']}> Mask Usernames in Logs
|
||||
</label>
|
||||
<p class="help-block">
|
||||
Enable to mask Plex usernames and Tautulli friendly names with asterisks (*) in the logs.<br />
|
||||
Enable to mask Plex usernames with asterisks (*) in the logs.<br />
|
||||
Note: Only logs from the time this setting is enabled will be masked.
|
||||
</p>
|
||||
</div>
|
||||
|
@ -213,6 +207,39 @@
|
|||
</div>
|
||||
<p class="help-block">Set the percentage for a music track to be considered as listened. Minimum 50, Maximum 95.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="music_watched_percent">Video Watched Completion Behaviour</label>
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<select class="form-control" id="watched_marker" name="watched_marker">
|
||||
<option value="0" ${'selected' if config['watched_marker'] == 0 else ''}>At selected threshold percentage</option>
|
||||
<option value="1" ${'selected' if config['watched_marker'] == 1 else ''}>At final credits marker position</option>
|
||||
<option value="2" ${'selected' if config['watched_marker'] == 2 else ''}>At first credits marker position</option>
|
||||
<option value="3" ${'selected' if config['watched_marker'] == 3 else ''}>Earliest between threshold percent and first credits marker</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Decide whether to use end credits markers to determine the 'watched' state of video items. When markers are not available the selected threshold percentage will be used.</p>
|
||||
</div>
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
|
||||
</label>
|
||||
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label>Regroup Play History</label>
|
||||
<p class="help-block">
|
||||
Fix grouping of play history in the database.<br />
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="regroup_history">Regroup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label>Flush Temporary Sessions</label>
|
||||
<p class="help-block">
|
||||
|
@ -649,27 +676,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox advanced-setting">
|
||||
<label>
|
||||
<input type="checkbox" name="anon_redirect_dynamic" id="anon_redirect_dynamic" value="1" ${config['anon_redirect_dynamic']} /> Use Dynamic Anonymous Redirect Service
|
||||
</label>
|
||||
<p class="help-block">
|
||||
Allow Tautulli to use the dynamic anonymous redirect service listed <a href="${anon_url('https://tautulli.com/anonymizer.txt')}" target="_blank" rel="noreferrer">here</a>.
|
||||
Disable to specify your own service.
|
||||
</p>
|
||||
</div>
|
||||
<div id="anon_redirect_options">
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="anon_redirect">Anonymous Redirect Service</label>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="anon_redirect" name="anon_redirect" value="${config['anon_redirect']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Backlink protection via anonymizer service, must end in "?". Leave blank to disable.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="padded-header">
|
||||
<h3>Authentication</h3>
|
||||
</div>
|
||||
|
@ -761,7 +767,6 @@
|
|||
data-identifier="${config['pms_identifier']}"
|
||||
data-ip="${config['pms_ip']}"
|
||||
data-port="${config['pms_port']}"
|
||||
data-local="${int(not int(config['pms_is_remote']))}"
|
||||
data-ssl="${config['pms_ssl']}"
|
||||
data-is_cloud="${config['pms_is_cloud']}"
|
||||
data-label="${config['pms_name'] or 'Local'}"
|
||||
|
@ -794,13 +799,6 @@
|
|||
</label>
|
||||
<p class="help-block">Connect to your Plex server using HTTPS if you have <a href="${anon_url('https://support.plex.tv/articles/206225077-how-to-use-secure-server-connections')}" target="_blank" rel="noreferrer">secure connections</a> enabled.</p>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
|
||||
</label>
|
||||
<p class="help-block">Check this if your Plex Server is not on the same local network as Tautulli.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pms_url">Plex Server URL</label>
|
||||
<div class="row">
|
||||
|
@ -1251,7 +1249,7 @@
|
|||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
|
||||
Add a new notification agent, or configure an existing notification agent by clicking on the item below.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
Please see the <a href="${anon_url('https://github.com/%s/%s/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank" rel="noreferrer">Notification Agents Guide</a> for instructions on setting up each notification agent.
|
||||
|
@ -1271,7 +1269,7 @@
|
|||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
|
||||
Add a new newsletter agent, or configure an existing newsletter agent by clicking on the item below.
|
||||
</p>
|
||||
<p class="help-block settings-warning" id="newsletter_upload_warning">
|
||||
Warning: The <a data-tab-destination="3rd_party_apis" data-target="notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</span>
|
||||
|
@ -1582,7 +1580,7 @@
|
|||
Get the Tautulli Remote app on
|
||||
<a href="${anon_url('https://play.google.com/store/apps/details?id=com.tautulli.tautulli_remote')}" target="_blank" rel="noreferrer">Google Play</a>
|
||||
or the
|
||||
<a href="${anon_url('https://apps.apple.com/us/app/tautulli-remote/id1570909086')}" target="_blank" rel="noreferrer">App Store</a>
|
||||
<a href="${anon_url('https://apps.apple.com/us/app/tautulli-remote/id1570909086?platform=iphone')}" target="_blank" rel="noreferrer">App Store</a>
|
||||
to access Tautulli from your mobile device.
|
||||
<br />
|
||||
</p>
|
||||
|
@ -1591,7 +1589,7 @@
|
|||
<a href="${anon_url('https://play.google.com/store/apps/details?id=com.tautulli.tautulli_remote')}" target="_blank" rel="noreferrer"><img alt="Get it on Google Play" src="images/google-play-badge.svg" /></a>
|
||||
</span>
|
||||
<span class="app-badge">
|
||||
<a href="${anon_url('https://apps.apple.com/us/app/tautulli-remote/id1570909086')}" target="_blank" rel="noreferrer"><img alt="Download on the App Store" src="images/app-store-badge.svg" /></a>
|
||||
<a href="${anon_url('https://apps.apple.com/us/app/tautulli-remote/id1570909086?platform=iphone')}" target="_blank" rel="noreferrer"><img alt="Download on the App Store" src="images/app-store-badge.svg" /></a>
|
||||
</span>
|
||||
<span class="app-badge">
|
||||
<small>Google Play and the Google Play logo are trademarks of Google LLC.</small>
|
||||
|
@ -1603,7 +1601,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label>Registered Devices</label>
|
||||
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
|
||||
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking on the item below.</p>
|
||||
<p id="app_api_msg" style="color: #eb8600;">Warning: The API must be enabled under <a data-tab-destination="web_interface" data-target="api_enabled">Web Interface</a> to use the app.</p>
|
||||
<br />
|
||||
<div class="row">
|
||||
|
@ -1896,6 +1894,21 @@
|
|||
<p><strong style="color: #fff;">Example:</strong></p>
|
||||
<pre>{media_type} --> movie
|
||||
{media_type!c} --> Movie</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Time Formats</h4>
|
||||
</div>
|
||||
<div style="padding-bottom: 10px;">
|
||||
<p class="help-block">
|
||||
Notification parameters which are "in date format" or "in time format" can be formatted using the
|
||||
<a href="javascript:void(0)" data-target="#dateTimeOptionsModal" data-toggle="modal">Date & Time Format Options</a>
|
||||
by adding a <span class="inline-pre">:format</span> specifier.
|
||||
If no format is specified, the default Date Format and Time Format under Settings > General will be used.
|
||||
</p>
|
||||
<p><strong style="color: #fff;">Example:</strong></p>
|
||||
<pre>{started_datestamp:ddd, MMMM DD, YYYY} --> Mon, December 25, 2023
|
||||
{stopped_timestamp:h:mm a} --> 9:56 pm
|
||||
{duration_time:HH:mm:ss} --> 01:42:20</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4>List Slicing</h4>
|
||||
|
@ -1967,7 +1980,7 @@ Rating: {rating}/10 --> Rating: /10
|
|||
<li>Evaluation</li>
|
||||
<li>Parameter</li>
|
||||
<li>Case Modifier</li>
|
||||
<li>List Slicing</li>
|
||||
<li>Time Formats / List Slicing</li>
|
||||
<li>Suffix</li>
|
||||
</ol>
|
||||
<p><strong style="color: #fff;">Example:</strong></p>
|
||||
|
@ -2142,7 +2155,6 @@ Rating: {rating}/10 --> Rating: /10
|
|||
<script src="${http_root}js/parsley.min.js"></script>
|
||||
<script src="${http_root}js/Sortable.min.js"></script>
|
||||
<script src="${http_root}js/jquery.inputaffix.min.js"></script>
|
||||
<script src="${http_root}js/kjua.min.js"></script>
|
||||
<script>
|
||||
function getConfigurationTable() {
|
||||
$.ajax({
|
||||
|
@ -2374,7 +2386,6 @@ $(document).ready(function() {
|
|||
|
||||
initConfigCheckbox('#api_enabled');
|
||||
initConfigCheckbox('#enable_https');
|
||||
initConfigCheckbox('#anon_redirect_dynamic', null, true);
|
||||
initConfigCheckbox('#https_create_cert');
|
||||
initConfigCheckbox('#check_github');
|
||||
initConfigCheckbox('#monitor_pms_updates');
|
||||
|
@ -2470,6 +2481,12 @@ $(document).ready(function() {
|
|||
confirmAjaxCall(url, msg);
|
||||
});
|
||||
|
||||
$("#regroup_history").click(function () {
|
||||
var msg = 'Are you sure you want to regroup play history in the database?<br /><br /><strong>This make take a long time for large databases.<br />Regrouping will continue in the background.</strong>';
|
||||
var url = 'regroup_history';
|
||||
confirmAjaxCall(url, msg);
|
||||
});
|
||||
|
||||
$("#delete_temp_sessions").click(function () {
|
||||
var msg = 'Are you sure you want to flush the temporary sessions?<br /><br /><strong>This will reset all currently active sessions.</strong>';
|
||||
var url = 'delete_temp_sessions';
|
||||
|
@ -2571,7 +2588,6 @@ $(document).ready(function() {
|
|||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
|
@ -2585,7 +2601,6 @@ $(document).ready(function() {
|
|||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
|
@ -2608,7 +2623,6 @@ $(document).ready(function() {
|
|||
var identifier = $(pms_ip_selected).data('identifier');
|
||||
var ip = $(pms_ip_selected).data('ip');
|
||||
var port = $(pms_ip_selected).data('port');
|
||||
var local = $(pms_ip_selected).data('local');
|
||||
var ssl = $(pms_ip_selected).data('ssl');
|
||||
var is_cloud = $(pms_ip_selected).data('is_cloud');
|
||||
var value = $(pms_ip_selected).data('value');
|
||||
|
@ -2616,8 +2630,6 @@ $(document).ready(function() {
|
|||
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
|
||||
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
|
||||
$('#pms_port').val(port !== 'undefined' ? port : 32400);
|
||||
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
|
||||
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
|
||||
$('#pms_ssl_checkbox').prop('checked', (ssl !== 'undefined' && ssl === 1));
|
||||
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
|
||||
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
|
||||
|
@ -2655,7 +2667,6 @@ $(document).ready(function() {
|
|||
var pms_port = $("#pms_port").val();
|
||||
var pms_identifier = $("#pms_identifier").val();
|
||||
var pms_ssl = $("#pms_ssl").val();
|
||||
var pms_is_remote = $("#pms_is_remote").val();
|
||||
var pms_url_manual = $("#pms_url_manual").is(':checked') ? 1 : 0;
|
||||
|
||||
if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) {
|
||||
|
@ -2667,7 +2678,6 @@ $(document).ready(function() {
|
|||
hostname: pms_ip,
|
||||
port: pms_port,
|
||||
ssl: pms_ssl,
|
||||
remote: pms_is_remote,
|
||||
manual: pms_url_manual,
|
||||
get_url: true,
|
||||
test_websocket: true
|
||||
|
@ -2757,10 +2767,10 @@ $(document).ready(function() {
|
|||
function OAuthSuccessCallback(authToken) {
|
||||
$.post('save_pms_token', { token: authToken, client_id: $('#pms_client_id').val() }, function() {
|
||||
showMsg('<i class="fa fa-check"></i> Saved new Plex.tv token.', false, true, 5000);
|
||||
getServerOptions();
|
||||
});
|
||||
$("#pms-token-status").html('<i class="fa fa-check"></i> Authentication successful.').fadeIn('fast');
|
||||
$("#token_error_bar").hide();
|
||||
getServerOptions(authToken);
|
||||
}
|
||||
function OAuthErrorCallback() {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
||||
|
|
|
@ -68,14 +68,14 @@ DOCUMENTATION :: END
|
|||
<table class="stream-info" style="margin-top: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
</th>
|
||||
<th class="heading">
|
||||
Stream Details
|
||||
</th>
|
||||
<th></th>
|
||||
<th class="heading">
|
||||
Source Details
|
||||
</th>
|
||||
<th><i class="fa fa-long-arrow-right"></i></th>
|
||||
<th class="heading">
|
||||
Stream Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
|
@ -85,38 +85,46 @@ DOCUMENTATION :: END
|
|||
<th>
|
||||
Media
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
|
||||
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
|
||||
</tr>
|
||||
% if data['media_type'] != 'track':
|
||||
<tr>
|
||||
<td>Resolution</td>
|
||||
<td>${data['stream_video_full_resolution']}</td>
|
||||
<td>${data['video_full_resolution']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_full_resolution']}</td>
|
||||
</tr>
|
||||
% endif
|
||||
<tr>
|
||||
<td>Quality</td>
|
||||
<td>${data['quality_profile']}</td>
|
||||
<td>-</td>
|
||||
<td></td>
|
||||
<td>${data['quality_profile']}</td>
|
||||
</tr>
|
||||
% if data['optimized_version'] == 1:
|
||||
<tr>
|
||||
<td>Optimized Version</td>
|
||||
<td>-</td>
|
||||
<td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
% endif
|
||||
% if data['synced_version'] == 1:
|
||||
<tr>
|
||||
<td>Synced Version</td>
|
||||
<td>-</td>
|
||||
<td>${data['synced_version_profile']}</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
% endif
|
||||
</tbody>
|
||||
|
@ -127,6 +135,8 @@ DOCUMENTATION :: END
|
|||
<th>
|
||||
Container
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>
|
||||
${data['stream_container_decision']}
|
||||
</th>
|
||||
|
@ -135,8 +145,9 @@ DOCUMENTATION :: END
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>Container</td>
|
||||
<td>${data['stream_container'].upper()}</td>
|
||||
<td>${data['container'].upper()}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_container'].upper()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -147,6 +158,8 @@ DOCUMENTATION :: END
|
|||
<th>
|
||||
Video
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>
|
||||
${data['stream_video_decision']}
|
||||
</th>
|
||||
|
@ -155,38 +168,45 @@ DOCUMENTATION :: END
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>Codec</td>
|
||||
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
|
||||
<td>${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
|
||||
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Width</td>
|
||||
<td>${data['stream_video_width']}</td>
|
||||
<td>${data['video_width']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_width']}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Height</td>
|
||||
<td>${data['stream_video_height']}</td>
|
||||
<td>${data['video_height']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_height']}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Framerate</td>
|
||||
<td>${data['stream_video_framerate']}</td>
|
||||
<td>${data['video_framerate']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_framerate']}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dynamic Range</td>
|
||||
<td>${data['stream_video_dynamic_range']}</td>
|
||||
<td>${data['video_dynamic_range']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_video_dynamic_range']}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aspect Ratio</td>
|
||||
<td>-</td>
|
||||
<td>${data['aspect_ratio']}</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -197,6 +217,8 @@ DOCUMENTATION :: END
|
|||
<th>
|
||||
Audio
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>
|
||||
${data['stream_audio_decision']}
|
||||
</th>
|
||||
|
@ -205,26 +227,29 @@ DOCUMENTATION :: END
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>Codec</td>
|
||||
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
|
||||
<td>${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bitrate</td>
|
||||
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
|
||||
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Channels</td>
|
||||
<td>${data['stream_audio_channels']}</td>
|
||||
<td>${data['audio_channels']}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_audio_channels']}</td>
|
||||
</tr>
|
||||
% if data['stream_audio_language'] != '':
|
||||
<tr>
|
||||
<td>Language</td>
|
||||
<td>${data['stream_audio_language']}</td>
|
||||
<td>${data['audio_language']}</td>
|
||||
<td>${data['audio_language'] or 'Unknown'}</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
% endif
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
% if data['subtitles'] == 1:
|
||||
|
@ -234,17 +259,34 @@ DOCUMENTATION :: END
|
|||
<th>
|
||||
Subtitles
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>
|
||||
${data['stream_subtitle_decision']}
|
||||
${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Codec</td>
|
||||
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
|
||||
<td>${data['subtitle_codec'].upper()}</td>
|
||||
<td><i class="fa fa-long-arrow-right"></i></td>
|
||||
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Language</td>
|
||||
<td>${data['subtitle_language'] or 'Unknown'}</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
% if data['subtitle_forced']:
|
||||
<tr>
|
||||
<td>Forced</td>
|
||||
<td>${bool(data['subtitle_forced'])}</td>
|
||||
<td></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
% endif
|
||||
</tbody>
|
||||
</table>
|
||||
% endif
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<div class="iframe-container">
|
||||
<iframe class="iframe" allowfullscreen="true" id="support-iframe" data-name="Tautulli-Support" data-src="https://support.tautulli.com"
|
||||
sandbox="allow-presentation allow-forms allow-same-origin allow-pointer-lock allow-scripts allow-popups allow-modals allow-top-navigation"
|
||||
style="display: none;">
|
||||
style="display: none;" rel="noreferrer">
|
||||
</iframe>
|
||||
<div class="iframe-overlay">
|
||||
<div class="iframe-button-container">
|
||||
|
|
|
@ -233,8 +233,9 @@ DOCUMENTATION :: END
|
|||
data: {
|
||||
old_rating_key: '${query["rating_key"]}',
|
||||
new_rating_key: new_rating_key,
|
||||
media_type: '${query["media_type"]}'
|
||||
},
|
||||
media_type: '${query["media_type"]}',
|
||||
single_update: true
|
||||
},
|
||||
async: true,
|
||||
success: function (data) {
|
||||
window.location.href = new_href;
|
||||
|
|
|
@ -76,7 +76,6 @@ DOCUMENTATION :: END
|
|||
% if _session['user_group'] == 'admin':
|
||||
<li><a id="nav-tabs-export" href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
|
||||
% endif
|
||||
<li><a id="nav-tabs-synceditems" href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li>
|
||||
<li><a id="nav-tabs-ipaddresses" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li>
|
||||
<li><a id="nav-tabs-tautullilogins" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
|
||||
</ul>
|
||||
|
@ -126,10 +125,10 @@ DOCUMENTATION :: END
|
|||
<div class="table-card-header">
|
||||
<ul class="nav nav-header nav-dashboard pull-right">
|
||||
<li>
|
||||
<a href="#" id="recently-watched-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
<a href="#" id="recently-watched-page-left" class="paginate-watched btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" id="recently-watched-page-right" class="paginate btn-gray" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
<a href="#" id="recently-watched-page-right" class="paginate-watched btn-gray" data-id="+1"><i class="fa fa-lg fa-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="header-bar">
|
||||
|
@ -167,27 +166,27 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-movie" value="movie" autocomplete="off"><i class="fa fa-film"></i> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-episode" value="episode" autocomplete="off"><i class="fa fa-television"></i> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-track" value="track" autocomplete="off"><i class="fa fa-music"></i> Music
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="media_type-filter" id="history-media_type-live" value="live" autocomplete="off"><i class="fa fa-broadcast-tower"></i> Live TV
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group" data-toggle="buttons" id="transcode_decision-selection">
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_play" value="direct play" autocomplete="off"><i class="fa fa-play-circle"></i> Direct Play
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-direct_stream" value="copy" autocomplete="off"><i class="fa fa-stream"></i> Direct Stream
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<label class="btn btn-dark btn-filter">
|
||||
<input type="checkbox" name="transcode_decision-filter" id="history-transcode_decision-transcode" value="transcode" autocomplete="off"><i class="fa fa-server"></i> Transcode
|
||||
</label>
|
||||
</div>
|
||||
|
@ -212,7 +211,7 @@ DOCUMENTATION :: END
|
|||
<th align="left" id="started">Started</th>
|
||||
<th align="left" id="paused_counter">Paused</th>
|
||||
<th align="left" id="stopped">Stopped</th>
|
||||
<th align="left" id="duration">Duration</th>
|
||||
<th align="left" id="play_duration">Duration</th>
|
||||
<th align="left" id="percent_complete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -316,57 +315,6 @@ DOCUMENTATION :: END
|
|||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-synceditems">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class='table-card-header'>
|
||||
<div class="header-bar">
|
||||
<span>
|
||||
<i class="fa fa-cloud-download"></i> Synced Items for <strong>
|
||||
<span class="set-username">${data['friendly_name']}</span>
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="button-bar">
|
||||
% if _session['user_group'] == 'admin':
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
|
||||
<i class="fa fa-trash-o"></i> Delete mode
|
||||
</button>
|
||||
</div>
|
||||
% endif
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-dark refresh-syncs-button" id="refresh-syncs-list"><i class="fa fa-refresh"></i> Refresh synced items</button>
|
||||
</div>
|
||||
<div class="btn-group colvis-button-bar" id="button-bar-sync"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-back">
|
||||
<table class="display sync_table" id="sync_table-UID-${data['user_id']}" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" id="delete_row">Delete</th>
|
||||
<th align="left" id="state">State</th>
|
||||
<th align="left" id="username">Username</th>
|
||||
<th align="left" id="sync_title">Title</th>
|
||||
<th align="left" id="type">Type</th>
|
||||
<th align="left" id="sync_platform">Platform</th>
|
||||
<th align="left" id="device">Device</th>
|
||||
<th align="left" id="size">Total Size</th>
|
||||
<th align="left" id="items">Total Items</th>
|
||||
<th align="left" id="converted">Converted</th>
|
||||
<th align="left" id="downloaded">Downloaded</th>
|
||||
<th align="left" id="sync_percent_complete">Complete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-ipaddresses">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
|
@ -642,12 +590,6 @@ DOCUMENTATION :: END
|
|||
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
|
||||
}
|
||||
|
||||
$('#nav-tabs-synceditems').on('shown.bs.tab', function() {
|
||||
if (typeof(sync_table) === 'undefined') {
|
||||
loadSyncTable(user_id);
|
||||
}
|
||||
});
|
||||
|
||||
$("#refresh-syncs-list").click(function() {
|
||||
sync_table.ajax.reload();
|
||||
});
|
||||
|
@ -724,52 +666,14 @@ DOCUMENTATION :: END
|
|||
},
|
||||
complete: function(xhr, status) {
|
||||
$("#user-recently-watched").html(xhr.responseText);
|
||||
highlightWatchedScrollerButton();
|
||||
highlightScrollerButton("#recently-watched");
|
||||
paginateScroller("#recently-watched", ".paginate-watched");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recentlyWatched();
|
||||
|
||||
function highlightWatchedScrollerButton() {
|
||||
var scroller = $("#recently-watched-row-scroller");
|
||||
var numElems = scroller.find("li").length;
|
||||
scroller.width(numElems * 175);
|
||||
if (scroller.width() > $("#user-recently-watched").width()) {
|
||||
$("#recently-watched-page-right").removeClass("disabled");
|
||||
} else {
|
||||
$("#recently-watched-page-right").addClass("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
$(window).resize(function() {
|
||||
highlightWatchedScrollerButton();
|
||||
});
|
||||
|
||||
var leftTotal = 0;
|
||||
$(".paginate").click(function (e) {
|
||||
e.preventDefault();
|
||||
var scroller = $("#recently-watched-row-scroller");
|
||||
var containerWidth = $("#user-recently-watched").width();
|
||||
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
|
||||
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
|
||||
|
||||
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
|
||||
scroller.animate({ left: leftTotal }, 250);
|
||||
|
||||
if (leftTotal == 0) {
|
||||
$("#recently-watched-page-left").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-watched-page-left").removeClass("disabled");
|
||||
}
|
||||
|
||||
if (leftTotal == leftMax) {
|
||||
$("#recently-watched-page-right").addClass("disabled").blur();
|
||||
} else {
|
||||
$("#recently-watched-page-right").removeClass("disabled");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
// Javascript to enable link to tab
|
||||
var hash = document.location.hash;
|
||||
|
|
|
@ -31,7 +31,7 @@ DOCUMENTATION :: END
|
|||
from plexpy.helpers import page, short_season
|
||||
%>
|
||||
<div class="dashboard-recent-media-row">
|
||||
<div id="recently-watched-row-scroller" style="left: 0;">
|
||||
<div id="recently-watched-row-scroller">
|
||||
<ul class="dashboard-recent-media list-unstyled">
|
||||
% for item in data:
|
||||
<li>
|
||||
|
|
|
@ -33,6 +33,9 @@
|
|||
<th align="left" id="edit_row">Edit</th>
|
||||
<th align="right" id="avatar"></th>
|
||||
<th align="left" id="friendly_name">User</th>
|
||||
<th align="left" id="username">Username</th>
|
||||
<th align="left" id="title">Full Name</th>
|
||||
<th align="left" id="email">Email</th>
|
||||
<th align="left" id="last_seen">Last Streamed</th>
|
||||
<th align="left" id="last_known_ip">Last Known IP</th>
|
||||
<th align="left" id="last_platform">Last Platform</th>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet">
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
|
@ -27,7 +28,7 @@
|
|||
|
||||
<!-- ICONS -->
|
||||
<!-- Android -->
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.6.0" crossorigin="use-credentials">
|
||||
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.9.0" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="#282a2d">
|
||||
<!-- Apple -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.6.0">
|
||||
|
@ -134,7 +135,6 @@
|
|||
data-identifier="${config['pms_identifier']}"
|
||||
data-ip="${config['pms_ip']}"
|
||||
data-port="${config['pms_port']}"
|
||||
data-local="${int(not int(config['pms_is_remote']))}"
|
||||
data-ssl="${config['pms_ssl']}"
|
||||
data-is_cloud="${config['pms_is_cloud']}"
|
||||
data-label="${config['pms_name'] or 'Local'}"
|
||||
|
@ -150,7 +150,7 @@
|
|||
<div class="col-xs-3">
|
||||
<input type="text" class="form-control pms-settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<div class="col-xs-9">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" data-id="pms_ssl" value="1" ${helpers.checked(config['pms_ssl'])}> Use Secure Connection
|
||||
|
@ -158,14 +158,6 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${helpers.checked(config['pms_is_remote'])}> Remote Server
|
||||
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="pms_valid" data-validate="validatePMSip" value="">
|
||||
|
@ -229,23 +221,6 @@
|
|||
<!-- Required fields but hidden -->
|
||||
<div style="display: none;">
|
||||
<input type="checkbox" name="first_run" id="first_run" value="1" checked>
|
||||
<input type="checkbox" name="group_history_tables" id="group_history_tables" value="1" checked>
|
||||
<input type="checkbox" name="history_table_activity" id="history_table_activity" value="1" checked>
|
||||
<input type="checkbox" name="sys_tray_icon" id="sys_tray_icon" value="1" checked>
|
||||
<input type="checkbox" name="launch_startup" id="launch_startup" value="1" checked>
|
||||
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" checked>
|
||||
<input type="checkbox" name="api_enabled" id="api_enabled" value="1" checked>
|
||||
<input type="checkbox" name="refresh_users_on_startup" id="refresh_users_on_startup" value="1" checked>
|
||||
<input type="checkbox" name="refresh_libraries_on_startup" id="refresh_libraries_on_startup" value="1" checked>
|
||||
<input type="checkbox" name="check_github" id="check_github" value="1" checked>
|
||||
<input type="checkbox" name="log_blacklist" id="log_blacklist" value="1" checked>
|
||||
<input type="checkbox" name="cache_images" id="cache_images" value="1" checked>
|
||||
<input type="checkbox" name="notify_group_recently_added_grandparent" id="notify_group_recently_added_grandparent" value="1" checked>
|
||||
<input type="checkbox" name="notify_group_recently_added_parent" id="notify_group_recently_added_parent" value="1" checked>
|
||||
<input type="checkbox" name="server_changed" id="server_changed" value="1" checked>
|
||||
<input type="checkbox" name="first_run_complete" id="first_run_complete" value="1" checked>
|
||||
<input type="text" name="home_stats_cards" id="home_stats_cards" value="first_run_wizard">
|
||||
<input type="text" name="home_library_cards" id="home_library_cards" value="first_run_wizard">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -308,7 +283,7 @@
|
|||
var valid_pms_token = el.val();
|
||||
var retValue = {};
|
||||
|
||||
if (valid_pms_token === 0) {
|
||||
if (valid_pms_token !== "1") {
|
||||
retValue.status = false;
|
||||
retValue.msg = "Please authenticate.";
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Please authenticate.');
|
||||
|
@ -407,7 +382,6 @@ $(document).ready(function() {
|
|||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
|
@ -421,7 +395,6 @@ $(document).ready(function() {
|
|||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
|
@ -444,7 +417,6 @@ $(document).ready(function() {
|
|||
var identifier = $(pms_ip_selected).data('identifier');
|
||||
var ip = $(pms_ip_selected).data('ip');
|
||||
var port = $(pms_ip_selected).data('port');
|
||||
var local = $(pms_ip_selected).data('local');
|
||||
var ssl = $(pms_ip_selected).data('ssl');
|
||||
var is_cloud = $(pms_ip_selected).data('is_cloud');
|
||||
var value = $(pms_ip_selected).data('value');
|
||||
|
@ -455,19 +427,15 @@ $(document).ready(function() {
|
|||
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
|
||||
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
|
||||
$('#pms_port').val(port !== 'undefined' ? port : 32400);
|
||||
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
|
||||
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
|
||||
$('#pms_ssl_checkbox').prop('checked', (ssl !== 'undefined' && ssl === 1));
|
||||
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
|
||||
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
|
||||
|
||||
if (is_cloud === true) {
|
||||
$('#pms_port').prop('readonly', true);
|
||||
$('#pms_is_remote_checkbox').prop('disabled', true);
|
||||
$('#pms_ssl_checkbox').prop('disabled', true);
|
||||
} else {
|
||||
$('#pms_port').prop('readonly', false);
|
||||
$('#pms_is_remote_checkbox').prop('disabled', false);
|
||||
$('#pms_ssl_checkbox').prop('disabled', false);
|
||||
}
|
||||
},
|
||||
|
@ -504,7 +472,6 @@ $(document).ready(function() {
|
|||
var pms_port = $("#pms_port").val().trim();
|
||||
var pms_identifier = $("#pms_identifier").val();
|
||||
var pms_ssl = $("#pms_ssl").val();
|
||||
var pms_is_remote = $("#pms_is_remote").val();
|
||||
if ((pms_ip !== '') || (pms_port !== '')) {
|
||||
$("#pms-verify-status").html('<i class="fa fa-refresh fa-spin"></i> Verifying server...');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
|
@ -514,8 +481,7 @@ $(document).ready(function() {
|
|||
hostname: pms_ip,
|
||||
port: pms_port,
|
||||
identifier: pms_identifier,
|
||||
ssl: pms_ssl,
|
||||
remote: pms_is_remote
|
||||
ssl: pms_ssl
|
||||
},
|
||||
cache: true,
|
||||
async: true,
|
||||
|
@ -558,10 +524,10 @@ $(document).ready(function() {
|
|||
function OAuthSuccessCallback(authToken) {
|
||||
$.post('save_pms_token', { token: authToken, client_id: $('#pms_client_id').val() }, function () {
|
||||
$("#pms_token_validated").val(1);
|
||||
getServerOptions();
|
||||
});
|
||||
$("#pms-token-status").html('<i class="fa fa-check"></i> Authentication successful.').fadeIn('fast');
|
||||
authenticated = true;
|
||||
getServerOptions(authToken);
|
||||
}
|
||||
function OAuthErrorCallback() {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Tautulli Newsletter - ${subject}</title>
|
||||
<link rel="shortcut icon" href="${base_url_image + 'images/favicon/favicon.ico' if base_url_image else 'https://tautulli.com/images/favicon.ico'}">
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<title>Tautulli Newsletter - ${subject}</title>
|
||||
<link rel="shortcut icon" href="${base_url_image + 'images/favicon/favicon.ico' if base_url_image else 'https://tautulli.com/images/favicon.ico'}">
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
#!/usr/bin/python
|
||||
###############################################################################
|
||||
# Formatting filter for urllib2's HTTPHandler(debuglevel=1) output
|
||||
# Copyright (c) 2013, Analytics Pros
|
||||
#
|
||||
# This project is free software, distributed under the BSD license.
|
||||
# Analytics Pros offers consulting and integration services if your firm needs
|
||||
# assistance in strategy, implementation, or auditing existing work.
|
||||
###############################################################################
|
||||
|
||||
|
||||
import sys, re, os
|
||||
from io import StringIO
|
||||
|
||||
|
||||
|
||||
class BufferTranslator(object):
|
||||
""" Provides a buffer-compatible interface for filtering buffer content.
|
||||
"""
|
||||
parsers = []
|
||||
|
||||
def __init__(self, output):
|
||||
self.output = output
|
||||
self.encoding = getattr(output, 'encoding', None)
|
||||
|
||||
def write(self, content):
|
||||
content = self.translate(content)
|
||||
self.output.write(content)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def stripslashes(content):
|
||||
return content.decode('string_escape')
|
||||
|
||||
@staticmethod
|
||||
def addslashes(content):
|
||||
return content.encode('string_escape')
|
||||
|
||||
def translate(self, line):
|
||||
for pattern, method in self.parsers:
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
return method(match)
|
||||
|
||||
return line
|
||||
|
||||
|
||||
|
||||
class LineBufferTranslator(BufferTranslator):
|
||||
""" Line buffer implementation supports translation of line-format input
|
||||
even when input is not already line-buffered. Caches input until newlines
|
||||
occur, and then dispatches translated input to output buffer.
|
||||
"""
|
||||
def __init__(self, *a, **kw):
|
||||
self._linepending = []
|
||||
super(LineBufferTranslator, self).__init__(*a, **kw)
|
||||
|
||||
def write(self, _input):
|
||||
lines = _input.splitlines(True)
|
||||
for i in range(0, len(lines)):
|
||||
last = i
|
||||
if lines[i].endswith('\n'):
|
||||
prefix = len(self._linepending) and ''.join(self._linepending) or ''
|
||||
self.output.write(self.translate(prefix + lines[i]))
|
||||
del self._linepending[0:]
|
||||
last = -1
|
||||
|
||||
if last >= 0:
|
||||
self._linepending.append(lines[ last ])
|
||||
|
||||
|
||||
def __del__(self):
|
||||
if len(self._linepending):
|
||||
self.output.write(self.translate(''.join(self._linepending)))
|
||||
|
||||
|
||||
class HTTPTranslator(LineBufferTranslator):
|
||||
""" Translates output from |urllib2| HTTPHandler(debuglevel = 1) into
|
||||
HTTP-compatible, readible text structures for human analysis.
|
||||
"""
|
||||
|
||||
RE_LINE_PARSER = re.compile(r'^(?:([a-z]+):)\s*(\'?)([^\r\n]*)\2(?:[\r\n]*)$')
|
||||
RE_LINE_BREAK = re.compile(r'(\r?\n|(?:\\r)?\\n)')
|
||||
RE_HTTP_METHOD = re.compile(r'^(POST|GET|HEAD|DELETE|PUT|TRACE|OPTIONS)')
|
||||
RE_PARAMETER_SPACER = re.compile(r'&([a-z0-9]+)=')
|
||||
|
||||
@classmethod
|
||||
def spacer(cls, line):
|
||||
return cls.RE_PARAMETER_SPACER.sub(r' &\1= ', line)
|
||||
|
||||
def translate(self, line):
|
||||
|
||||
parsed = self.RE_LINE_PARSER.match(line)
|
||||
|
||||
if parsed:
|
||||
value = parsed.group(3)
|
||||
stage = parsed.group(1)
|
||||
|
||||
if stage == 'send': # query string is rendered here
|
||||
return '\n# HTTP Request:\n' + self.stripslashes(value)
|
||||
elif stage == 'reply':
|
||||
return '\n\n# HTTP Response:\n' + self.stripslashes(value)
|
||||
elif stage == 'header':
|
||||
return value + '\n'
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def consume(outbuffer = None): # Capture standard output
|
||||
sys.stdout = HTTPTranslator(outbuffer or sys.stdout)
|
||||
return sys.stdout
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
consume(sys.stdout).write(sys.stdin.read())
|
||||
print('\n')
|
||||
|
||||
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4
|
|
@ -1,424 +0,0 @@
|
|||
from future.moves.urllib.request import urlopen, build_opener, install_opener
|
||||
from future.moves.urllib.request import Request, HTTPSHandler
|
||||
from future.moves.urllib.error import URLError, HTTPError
|
||||
from future.moves.urllib.parse import urlencode
|
||||
|
||||
import random
|
||||
import datetime
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
import socket
|
||||
|
||||
|
||||
def generate_uuid(basedata=None):
|
||||
""" Provides a _random_ UUID with no input, or a UUID4-format MD5 checksum of any input data provided """
|
||||
if basedata is None:
|
||||
return str(uuid.uuid4())
|
||||
elif isinstance(basedata, str):
|
||||
checksum = hashlib.md5(str(basedata).encode('utf-8')).hexdigest()
|
||||
return '%8s-%4s-%4s-%4s-%12s' % (
|
||||
checksum[0:8], checksum[8:12], checksum[12:16], checksum[16:20], checksum[20:32])
|
||||
|
||||
|
||||
class Time(datetime.datetime):
|
||||
""" Wrappers and convenience methods for processing various time representations """
|
||||
|
||||
@classmethod
|
||||
def from_unix(cls, seconds, milliseconds=0):
|
||||
""" Produce a full |datetime.datetime| object from a Unix timestamp """
|
||||
base = list(time.gmtime(seconds))[0:6]
|
||||
base.append(milliseconds * 1000) # microseconds
|
||||
return cls(*base)
|
||||
|
||||
@classmethod
|
||||
def to_unix(cls, timestamp):
|
||||
""" Wrapper over time module to produce Unix epoch time as a float """
|
||||
if not isinstance(timestamp, datetime.datetime):
|
||||
raise TypeError('Time.milliseconds expects a datetime object')
|
||||
base = time.mktime(timestamp.timetuple())
|
||||
return base
|
||||
|
||||
@classmethod
|
||||
def milliseconds_offset(cls, timestamp, now=None):
|
||||
""" Offset time (in milliseconds) from a |datetime.datetime| object to now """
|
||||
if isinstance(timestamp, (int, float)):
|
||||
base = timestamp
|
||||
else:
|
||||
base = cls.to_unix(timestamp)
|
||||
base = base + (timestamp.microsecond / 1000000)
|
||||
if now is None:
|
||||
now = time.time()
|
||||
return (now - base) * 1000
|
||||
|
||||
|
||||
class HTTPRequest(object):
|
||||
""" URL Construction and request handling abstraction.
|
||||
This is not intended to be used outside this module.
|
||||
|
||||
Automates mapping of persistent state (i.e. query parameters)
|
||||
onto transcient datasets for each query.
|
||||
"""
|
||||
|
||||
endpoint = 'https://www.google-analytics.com/collect'
|
||||
|
||||
@staticmethod
|
||||
def debug():
|
||||
""" Activate debugging on urllib2 """
|
||||
handler = HTTPSHandler(debuglevel=1)
|
||||
opener = build_opener(handler)
|
||||
install_opener(opener)
|
||||
|
||||
# Store properties for all requests
|
||||
def __init__(self, user_agent=None, *args, **opts):
|
||||
self.user_agent = user_agent or 'Analytics Pros - Universal Analytics (Python)'
|
||||
|
||||
@classmethod
|
||||
def fixUTF8(cls, data): # Ensure proper encoding for UA's servers...
|
||||
""" Convert all strings to UTF-8 """
|
||||
for key in data:
|
||||
if isinstance(data[key], str):
|
||||
data[key] = data[key].encode('utf-8')
|
||||
return data
|
||||
|
||||
# Apply stored properties to the given dataset & POST to the configured endpoint
|
||||
def send(self, data):
|
||||
request = Request(
|
||||
self.endpoint + '?' + urlencode(self.fixUTF8(data)).encode('utf-8'),
|
||||
headers={
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
)
|
||||
self.open(request)
|
||||
|
||||
def open(self, request):
|
||||
try:
|
||||
return urlopen(request)
|
||||
except HTTPError as e:
|
||||
return False
|
||||
except URLError as e:
|
||||
self.cache_request(request)
|
||||
return False
|
||||
|
||||
def cache_request(self, request):
|
||||
# TODO: implement a proper caching mechanism here for re-transmitting hits
|
||||
# record = (Time.now(), request.get_full_url(), request.get_data(), request.headers)
|
||||
pass
|
||||
|
||||
|
||||
class HTTPPost(HTTPRequest):
|
||||
|
||||
# Apply stored properties to the given dataset & POST to the configured endpoint
|
||||
def send(self, data):
|
||||
request = Request(
|
||||
self.endpoint,
|
||||
data=urlencode(self.fixUTF8(data)).encode('utf-8'),
|
||||
headers={
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
)
|
||||
self.open(request)
|
||||
|
||||
|
||||
class Tracker(object):
|
||||
""" Primary tracking interface for Universal Analytics """
|
||||
params = None
|
||||
parameter_alias = {}
|
||||
valid_hittypes = ('pageview', 'event', 'social', 'screenview', 'transaction', 'item', 'exception', 'timing')
|
||||
|
||||
@classmethod
|
||||
def alias(cls, typemap, base, *names):
|
||||
""" Declare an alternate (humane) name for a measurement protocol parameter """
|
||||
cls.parameter_alias[base] = (typemap, base)
|
||||
for i in names:
|
||||
cls.parameter_alias[i] = (typemap, base)
|
||||
|
||||
@classmethod
|
||||
def coerceParameter(cls, name, value=None):
|
||||
if isinstance(name, str) and name[0] == '&':
|
||||
return name[1:], str(value)
|
||||
elif name in cls.parameter_alias:
|
||||
typecast, param_name = cls.parameter_alias.get(name)
|
||||
return param_name, typecast(value)
|
||||
else:
|
||||
raise KeyError('Parameter "{0}" is not recognized'.format(name))
|
||||
|
||||
def payload(self, data):
|
||||
for key, value in data.items():
|
||||
try:
|
||||
yield self.coerceParameter(key, value)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
option_sequence = {
|
||||
'pageview': [(str, 'dp')],
|
||||
'event': [(str, 'ec'), (str, 'ea'), (str, 'el'), (int, 'ev')],
|
||||
'social': [(str, 'sn'), (str, 'sa'), (str, 'st')],
|
||||
'timing': [(str, 'utc'), (str, 'utv'), (str, 'utt'), (str, 'utl')]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def consume_options(cls, data, hittype, args):
|
||||
""" Interpret sequential arguments related to known hittypes based on declared structures """
|
||||
opt_position = 0
|
||||
data['t'] = hittype # integrate hit type parameter
|
||||
if hittype in cls.option_sequence:
|
||||
for expected_type, optname in cls.option_sequence[hittype]:
|
||||
if opt_position < len(args) and isinstance(args[opt_position], expected_type):
|
||||
data[optname] = args[opt_position]
|
||||
opt_position += 1
|
||||
|
||||
@classmethod
|
||||
def hittime(cls, timestamp=None, age=None, milliseconds=None):
|
||||
""" Returns an integer represeting the milliseconds offset for a given hit (relative to now) """
|
||||
if isinstance(timestamp, (int, float)):
|
||||
return int(Time.milliseconds_offset(Time.from_unix(timestamp, milliseconds=milliseconds)))
|
||||
if isinstance(timestamp, datetime.datetime):
|
||||
return int(Time.milliseconds_offset(timestamp))
|
||||
if isinstance(age, (int, float)):
|
||||
return int(age * 1000) + (milliseconds or 0)
|
||||
|
||||
@property
|
||||
def account(self):
|
||||
return self.params.get('tid', None)
|
||||
|
||||
def __init__(self, account, name=None, client_id=None, hash_client_id=False, user_id=None, user_agent=None,
|
||||
use_post=True):
|
||||
|
||||
if use_post is False:
|
||||
self.http = HTTPRequest(user_agent=user_agent)
|
||||
else:
|
||||
self.http = HTTPPost(user_agent=user_agent)
|
||||
|
||||
self.params = {'v': 1, 'tid': account}
|
||||
|
||||
if client_id is None:
|
||||
client_id = generate_uuid()
|
||||
|
||||
self.params['cid'] = client_id
|
||||
|
||||
self.hash_client_id = hash_client_id
|
||||
|
||||
if user_id is not None:
|
||||
self.params['uid'] = user_id
|
||||
|
||||
def set_timestamp(self, data):
|
||||
""" Interpret time-related options, apply queue-time parameter as needed """
|
||||
if 'hittime' in data: # an absolute timestamp
|
||||
data['qt'] = self.hittime(timestamp=data.pop('hittime', None))
|
||||
if 'hitage' in data: # a relative age (in seconds)
|
||||
data['qt'] = self.hittime(age=data.pop('hitage', None))
|
||||
|
||||
def send(self, hittype, *args, **data):
|
||||
""" Transmit HTTP requests to Google Analytics using the measurement protocol """
|
||||
|
||||
if hittype not in self.valid_hittypes:
|
||||
raise KeyError('Unsupported Universal Analytics Hit Type: {0}'.format(repr(hittype)))
|
||||
|
||||
self.set_timestamp(data)
|
||||
self.consume_options(data, hittype, args)
|
||||
|
||||
for item in args: # process dictionary-object arguments of transcient data
|
||||
if isinstance(item, dict):
|
||||
for key, val in self.payload(item):
|
||||
data[key] = val
|
||||
|
||||
for k, v in self.params.items(): # update only absent parameters
|
||||
if k not in data:
|
||||
data[k] = v
|
||||
|
||||
data = dict(self.payload(data))
|
||||
|
||||
if self.hash_client_id:
|
||||
data['cid'] = generate_uuid(data['cid'])
|
||||
|
||||
# Transmit the hit to Google...
|
||||
self.http.send(data)
|
||||
|
||||
# Setting persistent attibutes of the session/hit/etc (inc. custom dimensions/metrics)
|
||||
def set(self, name, value=None):
|
||||
if isinstance(name, dict):
|
||||
for key, value in name.items():
|
||||
try:
|
||||
param, value = self.coerceParameter(key, value)
|
||||
self.params[param] = value
|
||||
except KeyError:
|
||||
pass
|
||||
elif isinstance(name, str):
|
||||
try:
|
||||
param, value = self.coerceParameter(name, value)
|
||||
self.params[param] = value
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __getitem__(self, name):
|
||||
param, value = self.coerceParameter(name, None)
|
||||
return self.params.get(param, None)
|
||||
|
||||
def __setitem__(self, name, value):
|
||||
param, value = self.coerceParameter(name, value)
|
||||
self.params[param] = value
|
||||
|
||||
def __delitem__(self, name):
|
||||
param, value = self.coerceParameter(name, None)
|
||||
if param in self.params:
|
||||
del self.params[param]
|
||||
|
||||
|
||||
def safe_unicode(obj):
|
||||
""" Safe convertion to the Unicode string version of the object """
|
||||
try:
|
||||
return str(obj)
|
||||
except UnicodeDecodeError:
|
||||
return obj.decode('utf-8')
|
||||
|
||||
|
||||
# Declaring name mappings for Measurement Protocol parameters
|
||||
MAX_CUSTOM_DEFINITIONS = 200
|
||||
MAX_EC_LISTS = 11 # 1-based index
|
||||
MAX_EC_PRODUCTS = 11 # 1-based index
|
||||
MAX_EC_PROMOTIONS = 11 # 1-based index
|
||||
|
||||
Tracker.alias(int, 'v', 'protocol-version')
|
||||
Tracker.alias(safe_unicode, 'cid', 'client-id', 'clientId', 'clientid')
|
||||
Tracker.alias(safe_unicode, 'tid', 'trackingId', 'account')
|
||||
Tracker.alias(safe_unicode, 'uid', 'user-id', 'userId', 'userid')
|
||||
Tracker.alias(safe_unicode, 'uip', 'user-ip', 'userIp', 'ipaddr')
|
||||
Tracker.alias(safe_unicode, 'ua', 'userAgent', 'userAgentOverride', 'user-agent')
|
||||
Tracker.alias(safe_unicode, 'dp', 'page', 'path')
|
||||
Tracker.alias(safe_unicode, 'dt', 'title', 'pagetitle', 'pageTitle' 'page-title')
|
||||
Tracker.alias(safe_unicode, 'dl', 'location')
|
||||
Tracker.alias(safe_unicode, 'dh', 'hostname')
|
||||
Tracker.alias(safe_unicode, 'sc', 'sessioncontrol', 'session-control', 'sessionControl')
|
||||
Tracker.alias(safe_unicode, 'dr', 'referrer', 'referer')
|
||||
Tracker.alias(int, 'qt', 'queueTime', 'queue-time')
|
||||
Tracker.alias(safe_unicode, 't', 'hitType', 'hittype')
|
||||
Tracker.alias(int, 'aip', 'anonymizeIp', 'anonIp', 'anonymize-ip')
|
||||
Tracker.alias(safe_unicode, 'ds', 'dataSource', 'data-source')
|
||||
|
||||
# Campaign attribution
|
||||
Tracker.alias(safe_unicode, 'cn', 'campaign', 'campaignName', 'campaign-name')
|
||||
Tracker.alias(safe_unicode, 'cs', 'source', 'campaignSource', 'campaign-source')
|
||||
Tracker.alias(safe_unicode, 'cm', 'medium', 'campaignMedium', 'campaign-medium')
|
||||
Tracker.alias(safe_unicode, 'ck', 'keyword', 'campaignKeyword', 'campaign-keyword')
|
||||
Tracker.alias(safe_unicode, 'cc', 'content', 'campaignContent', 'campaign-content')
|
||||
Tracker.alias(safe_unicode, 'ci', 'campaignId', 'campaignID', 'campaign-id')
|
||||
|
||||
# Technical specs
|
||||
Tracker.alias(safe_unicode, 'sr', 'screenResolution', 'screen-resolution', 'resolution')
|
||||
Tracker.alias(safe_unicode, 'vp', 'viewport', 'viewportSize', 'viewport-size')
|
||||
Tracker.alias(safe_unicode, 'de', 'encoding', 'documentEncoding', 'document-encoding')
|
||||
Tracker.alias(int, 'sd', 'colors', 'screenColors', 'screen-colors')
|
||||
Tracker.alias(safe_unicode, 'ul', 'language', 'user-language', 'userLanguage')
|
||||
|
||||
# Mobile app
|
||||
Tracker.alias(safe_unicode, 'an', 'appName', 'app-name', 'app')
|
||||
Tracker.alias(safe_unicode, 'cd', 'contentDescription', 'screenName', 'screen-name', 'content-description')
|
||||
Tracker.alias(safe_unicode, 'av', 'appVersion', 'app-version', 'version')
|
||||
Tracker.alias(safe_unicode, 'aid', 'appID', 'appId', 'application-id', 'app-id', 'applicationId')
|
||||
Tracker.alias(safe_unicode, 'aiid', 'appInstallerId', 'app-installer-id')
|
||||
|
||||
# Ecommerce
|
||||
Tracker.alias(safe_unicode, 'ta', 'affiliation', 'transactionAffiliation', 'transaction-affiliation')
|
||||
Tracker.alias(safe_unicode, 'ti', 'transaction', 'transactionId', 'transaction-id')
|
||||
Tracker.alias(float, 'tr', 'revenue', 'transactionRevenue', 'transaction-revenue')
|
||||
Tracker.alias(float, 'ts', 'shipping', 'transactionShipping', 'transaction-shipping')
|
||||
Tracker.alias(float, 'tt', 'tax', 'transactionTax', 'transaction-tax')
|
||||
Tracker.alias(safe_unicode, 'cu', 'currency', 'transactionCurrency',
|
||||
'transaction-currency') # Currency code, e.g. USD, EUR
|
||||
Tracker.alias(safe_unicode, 'in', 'item-name', 'itemName')
|
||||
Tracker.alias(float, 'ip', 'item-price', 'itemPrice')
|
||||
Tracker.alias(float, 'iq', 'item-quantity', 'itemQuantity')
|
||||
Tracker.alias(safe_unicode, 'ic', 'item-code', 'sku', 'itemCode')
|
||||
Tracker.alias(safe_unicode, 'iv', 'item-variation', 'item-category', 'itemCategory', 'itemVariation')
|
||||
|
||||
# Events
|
||||
Tracker.alias(safe_unicode, 'ec', 'event-category', 'eventCategory', 'category')
|
||||
Tracker.alias(safe_unicode, 'ea', 'event-action', 'eventAction', 'action')
|
||||
Tracker.alias(safe_unicode, 'el', 'event-label', 'eventLabel', 'label')
|
||||
Tracker.alias(int, 'ev', 'event-value', 'eventValue', 'value')
|
||||
Tracker.alias(int, 'ni', 'noninteractive', 'nonInteractive', 'noninteraction', 'nonInteraction')
|
||||
|
||||
# Social
|
||||
Tracker.alias(safe_unicode, 'sa', 'social-action', 'socialAction')
|
||||
Tracker.alias(safe_unicode, 'sn', 'social-network', 'socialNetwork')
|
||||
Tracker.alias(safe_unicode, 'st', 'social-target', 'socialTarget')
|
||||
|
||||
# Exceptions
|
||||
Tracker.alias(safe_unicode, 'exd', 'exception-description', 'exceptionDescription', 'exDescription')
|
||||
Tracker.alias(int, 'exf', 'exception-fatal', 'exceptionFatal', 'exFatal')
|
||||
|
||||
# User Timing
|
||||
Tracker.alias(safe_unicode, 'utc', 'timingCategory', 'timing-category')
|
||||
Tracker.alias(safe_unicode, 'utv', 'timingVariable', 'timing-variable')
|
||||
Tracker.alias(float, 'utt', 'time', 'timingTime', 'timing-time')
|
||||
Tracker.alias(safe_unicode, 'utl', 'timingLabel', 'timing-label')
|
||||
Tracker.alias(float, 'dns', 'timingDNS', 'timing-dns')
|
||||
Tracker.alias(float, 'pdt', 'timingPageLoad', 'timing-page-load')
|
||||
Tracker.alias(float, 'rrt', 'timingRedirect', 'timing-redirect')
|
||||
Tracker.alias(safe_unicode, 'tcp', 'timingTCPConnect', 'timing-tcp-connect')
|
||||
Tracker.alias(safe_unicode, 'srt', 'timingServerResponse', 'timing-server-response')
|
||||
|
||||
# Custom dimensions and metrics
|
||||
for i in range(0, 200):
|
||||
Tracker.alias(safe_unicode, 'cd{0}'.format(i), 'dimension{0}'.format(i))
|
||||
Tracker.alias(int, 'cm{0}'.format(i), 'metric{0}'.format(i))
|
||||
|
||||
# Content groups
|
||||
for i in range(0, 5):
|
||||
Tracker.alias(safe_unicode, 'cg{0}'.format(i), 'contentGroup{0}'.format(i))
|
||||
|
||||
# Enhanced Ecommerce
|
||||
Tracker.alias(str, 'pa') # Product action
|
||||
Tracker.alias(str, 'tcc') # Coupon code
|
||||
Tracker.alias(str, 'pal') # Product action list
|
||||
Tracker.alias(int, 'cos') # Checkout step
|
||||
Tracker.alias(str, 'col') # Checkout step option
|
||||
|
||||
Tracker.alias(str, 'promoa') # Promotion action
|
||||
|
||||
for product_index in range(1, MAX_EC_PRODUCTS):
|
||||
Tracker.alias(str, 'pr{0}id'.format(product_index)) # Product SKU
|
||||
Tracker.alias(str, 'pr{0}nm'.format(product_index)) # Product name
|
||||
Tracker.alias(str, 'pr{0}br'.format(product_index)) # Product brand
|
||||
Tracker.alias(str, 'pr{0}ca'.format(product_index)) # Product category
|
||||
Tracker.alias(str, 'pr{0}va'.format(product_index)) # Product variant
|
||||
Tracker.alias(str, 'pr{0}pr'.format(product_index)) # Product price
|
||||
Tracker.alias(int, 'pr{0}qt'.format(product_index)) # Product quantity
|
||||
Tracker.alias(str, 'pr{0}cc'.format(product_index)) # Product coupon code
|
||||
Tracker.alias(int, 'pr{0}ps'.format(product_index)) # Product position
|
||||
|
||||
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
|
||||
Tracker.alias(str, 'pr{0}cd{1}'.format(product_index, custom_index)) # Product custom dimension
|
||||
Tracker.alias(int, 'pr{0}cm{1}'.format(product_index, custom_index)) # Product custom metric
|
||||
|
||||
for list_index in range(1, MAX_EC_LISTS):
|
||||
Tracker.alias(str, 'il{0}pi{1}id'.format(list_index, product_index)) # Product impression SKU
|
||||
Tracker.alias(str, 'il{0}pi{1}nm'.format(list_index, product_index)) # Product impression name
|
||||
Tracker.alias(str, 'il{0}pi{1}br'.format(list_index, product_index)) # Product impression brand
|
||||
Tracker.alias(str, 'il{0}pi{1}ca'.format(list_index, product_index)) # Product impression category
|
||||
Tracker.alias(str, 'il{0}pi{1}va'.format(list_index, product_index)) # Product impression variant
|
||||
Tracker.alias(int, 'il{0}pi{1}ps'.format(list_index, product_index)) # Product impression position
|
||||
Tracker.alias(int, 'il{0}pi{1}pr'.format(list_index, product_index)) # Product impression price
|
||||
|
||||
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
|
||||
Tracker.alias(str, 'il{0}pi{1}cd{2}'.format(list_index, product_index,
|
||||
custom_index)) # Product impression custom dimension
|
||||
Tracker.alias(int, 'il{0}pi{1}cm{2}'.format(list_index, product_index,
|
||||
custom_index)) # Product impression custom metric
|
||||
|
||||
for list_index in range(1, MAX_EC_LISTS):
|
||||
Tracker.alias(str, 'il{0}nm'.format(list_index)) # Product impression list name
|
||||
|
||||
for promotion_index in range(1, MAX_EC_PROMOTIONS):
|
||||
Tracker.alias(str, 'promo{0}id'.format(promotion_index)) # Promotion ID
|
||||
Tracker.alias(str, 'promo{0}nm'.format(promotion_index)) # Promotion name
|
||||
Tracker.alias(str, 'promo{0}cr'.format(promotion_index)) # Promotion creative
|
||||
Tracker.alias(str, 'promo{0}ps'.format(promotion_index)) # Promotion position
|
||||
|
||||
|
||||
# Shortcut for creating trackers
|
||||
def create(account, *args, **kwargs):
|
||||
return Tracker(account, *args, **kwargs)
|
||||
|
||||
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4
|
|
@ -1 +0,0 @@
|
|||
from . import Tracker
|
396
lib/annotated_types/__init__.py
Normal file
|
@ -0,0 +1,396 @@
|
|||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import timezone
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union
|
||||
|
||||
if sys.version_info < (3, 8):
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
else:
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing_extensions import Annotated, Literal
|
||||
else:
|
||||
from typing import Annotated, Literal
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
EllipsisType = type(Ellipsis)
|
||||
KW_ONLY = {}
|
||||
SLOTS = {}
|
||||
else:
|
||||
from types import EllipsisType
|
||||
|
||||
KW_ONLY = {"kw_only": True}
|
||||
SLOTS = {"slots": True}
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseMetadata',
|
||||
'GroupedMetadata',
|
||||
'Gt',
|
||||
'Ge',
|
||||
'Lt',
|
||||
'Le',
|
||||
'Interval',
|
||||
'MultipleOf',
|
||||
'MinLen',
|
||||
'MaxLen',
|
||||
'Len',
|
||||
'Timezone',
|
||||
'Predicate',
|
||||
'LowerCase',
|
||||
'UpperCase',
|
||||
'IsDigits',
|
||||
'IsFinite',
|
||||
'IsNotFinite',
|
||||
'IsNan',
|
||||
'IsNotNan',
|
||||
'IsInfinite',
|
||||
'IsNotInfinite',
|
||||
'doc',
|
||||
'DocInfo',
|
||||
'__version__',
|
||||
)
|
||||
|
||||
__version__ = '0.6.0'
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
# arguments that start with __ are considered
|
||||
# positional only
|
||||
# see https://peps.python.org/pep-0484/#positional-only-arguments
|
||||
|
||||
|
||||
class SupportsGt(Protocol):
|
||||
def __gt__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsGe(Protocol):
|
||||
def __ge__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsLt(Protocol):
|
||||
def __lt__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsLe(Protocol):
|
||||
def __le__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsMod(Protocol):
|
||||
def __mod__(self: T, __other: T) -> T:
|
||||
...
|
||||
|
||||
|
||||
class SupportsDiv(Protocol):
|
||||
def __div__(self: T, __other: T) -> T:
|
||||
...
|
||||
|
||||
|
||||
class BaseMetadata:
|
||||
"""Base class for all metadata.
|
||||
|
||||
This exists mainly so that implementers
|
||||
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Gt(BaseMetadata):
|
||||
"""Gt(gt=x) implies that the value must be greater than x.
|
||||
|
||||
It can be used with any type that supports the ``>`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
gt: SupportsGt
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Ge(BaseMetadata):
|
||||
"""Ge(ge=x) implies that the value must be greater than or equal to x.
|
||||
|
||||
It can be used with any type that supports the ``>=`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
ge: SupportsGe
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Lt(BaseMetadata):
|
||||
"""Lt(lt=x) implies that the value must be less than x.
|
||||
|
||||
It can be used with any type that supports the ``<`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
lt: SupportsLt
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Le(BaseMetadata):
|
||||
"""Le(le=x) implies that the value must be less than or equal to x.
|
||||
|
||||
It can be used with any type that supports the ``<=`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
le: SupportsLe
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GroupedMetadata(Protocol):
|
||||
"""A grouping of multiple BaseMetadata objects.
|
||||
|
||||
`GroupedMetadata` on its own is not metadata and has no meaning.
|
||||
All it the the constraint and metadata should be fully expressable
|
||||
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
|
||||
|
||||
Concrete implementations should override `GroupedMetadata.__iter__()`
|
||||
to add their own metadata.
|
||||
For example:
|
||||
|
||||
>>> @dataclass
|
||||
>>> class Field(GroupedMetadata):
|
||||
>>> gt: float | None = None
|
||||
>>> description: str | None = None
|
||||
...
|
||||
>>> def __iter__(self) -> Iterable[BaseMetadata]:
|
||||
>>> if self.gt is not None:
|
||||
>>> yield Gt(self.gt)
|
||||
>>> if self.description is not None:
|
||||
>>> yield Description(self.gt)
|
||||
|
||||
Also see the implementation of `Interval` below for an example.
|
||||
|
||||
Parsers should recognize this and unpack it so that it can be used
|
||||
both with and without unpacking:
|
||||
|
||||
- `Annotated[int, Field(...)]` (parser must unpack Field)
|
||||
- `Annotated[int, *Field(...)]` (PEP-646)
|
||||
""" # noqa: trailing-whitespace
|
||||
|
||||
@property
|
||||
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
|
||||
return True
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]:
|
||||
...
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
__slots__ = () # allow subclasses to use slots
|
||||
|
||||
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
|
||||
# Basic ABC like functionality without the complexity of an ABC
|
||||
super().__init_subclass__(*args, **kwargs)
|
||||
if cls.__iter__ is GroupedMetadata.__iter__:
|
||||
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]: # noqa: F811
|
||||
raise NotImplementedError # more helpful than "None has no attribute..." type errors
|
||||
|
||||
|
||||
@dataclass(frozen=True, **KW_ONLY, **SLOTS)
|
||||
class Interval(GroupedMetadata):
|
||||
"""Interval can express inclusive or exclusive bounds with a single object.
|
||||
|
||||
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
|
||||
are interpreted the same way as the single-bound constraints.
|
||||
"""
|
||||
|
||||
gt: Union[SupportsGt, None] = None
|
||||
ge: Union[SupportsGe, None] = None
|
||||
lt: Union[SupportsLt, None] = None
|
||||
le: Union[SupportsLe, None] = None
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]:
|
||||
"""Unpack an Interval into zero or more single-bounds."""
|
||||
if self.gt is not None:
|
||||
yield Gt(self.gt)
|
||||
if self.ge is not None:
|
||||
yield Ge(self.ge)
|
||||
if self.lt is not None:
|
||||
yield Lt(self.lt)
|
||||
if self.le is not None:
|
||||
yield Le(self.le)
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MultipleOf(BaseMetadata):
|
||||
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
|
||||
|
||||
1. Python semantics, implying ``value % multiple_of == 0``, or
|
||||
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
|
||||
|
||||
We encourage users to be aware of these two common interpretations,
|
||||
and libraries to carefully document which they implement.
|
||||
"""
|
||||
|
||||
multiple_of: Union[SupportsDiv, SupportsMod]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MinLen(BaseMetadata):
|
||||
"""
|
||||
MinLen() implies minimum inclusive length,
|
||||
e.g. ``len(value) >= min_length``.
|
||||
"""
|
||||
|
||||
min_length: Annotated[int, Ge(0)]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MaxLen(BaseMetadata):
|
||||
"""
|
||||
MaxLen() implies maximum inclusive length,
|
||||
e.g. ``len(value) <= max_length``.
|
||||
"""
|
||||
|
||||
max_length: Annotated[int, Ge(0)]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Len(GroupedMetadata):
|
||||
"""
|
||||
Len() implies that ``min_length <= len(value) <= max_length``.
|
||||
|
||||
Upper bound may be omitted or ``None`` to indicate no upper length bound.
|
||||
"""
|
||||
|
||||
min_length: Annotated[int, Ge(0)] = 0
|
||||
max_length: Optional[Annotated[int, Ge(0)]] = None
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]:
|
||||
"""Unpack a Len into zone or more single-bounds."""
|
||||
if self.min_length > 0:
|
||||
yield MinLen(self.min_length)
|
||||
if self.max_length is not None:
|
||||
yield MaxLen(self.max_length)
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Timezone(BaseMetadata):
|
||||
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
|
||||
|
||||
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
|
||||
``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be
|
||||
tz-aware but any timezone is allowed.
|
||||
|
||||
You may also pass a specific timezone string or timezone object such as
|
||||
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
|
||||
you only allow a specific timezone, though we note that this is often
|
||||
a symptom of poor design.
|
||||
"""
|
||||
|
||||
tz: Union[str, timezone, EllipsisType, None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Predicate(BaseMetadata):
|
||||
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
|
||||
|
||||
Users should prefer statically inspectable metadata, but if you need the full
|
||||
power and flexibility of arbitrary runtime predicates... here it is.
|
||||
|
||||
We provide a few predefined predicates for common string constraints:
|
||||
``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and
|
||||
``IsDigit = Predicate(str.isdigit)``. Users are encouraged to use methods which
|
||||
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
|
||||
|
||||
Some libraries might have special logic to handle certain predicates, e.g. by
|
||||
checking for `str.isdigit` and using its presence to both call custom logic to
|
||||
enforce digit-only strings, and customise some generated external schema.
|
||||
|
||||
We do not specify what behaviour should be expected for predicates that raise
|
||||
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
|
||||
skip invalid constraints, or statically raise an error; or it might try calling it
|
||||
and then propogate or discard the resulting exception.
|
||||
"""
|
||||
|
||||
func: Callable[[Any], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Not:
|
||||
func: Callable[[Any], bool]
|
||||
|
||||
def __call__(self, __v: Any) -> bool:
|
||||
return not self.func(__v)
|
||||
|
||||
|
||||
_StrType = TypeVar("_StrType", bound=str)
|
||||
|
||||
LowerCase = Annotated[_StrType, Predicate(str.islower)]
|
||||
"""
|
||||
Return True if the string is a lowercase string, False otherwise.
|
||||
|
||||
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
|
||||
""" # noqa: E501
|
||||
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
|
||||
"""
|
||||
Return True if the string is an uppercase string, False otherwise.
|
||||
|
||||
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
|
||||
""" # noqa: E501
|
||||
IsDigits = Annotated[_StrType, Predicate(str.isdigit)]
|
||||
"""
|
||||
Return True if the string is a digit string, False otherwise.
|
||||
|
||||
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
|
||||
""" # noqa: E501
|
||||
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
|
||||
"""
|
||||
Return True if all characters in the string are ASCII, False otherwise.
|
||||
|
||||
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
|
||||
"""
|
||||
|
||||
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
|
||||
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
|
||||
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
|
||||
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
|
||||
"""Return True if x is one of infinity or NaN, and False otherwise"""
|
||||
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
|
||||
"""Return True if x is a NaN (not a number), and False otherwise."""
|
||||
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
|
||||
"""Return True if x is anything but NaN (not a number), and False otherwise."""
|
||||
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
|
||||
"""Return True if x is a positive or negative infinity, and False otherwise."""
|
||||
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
|
||||
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
|
||||
|
||||
try:
|
||||
from typing_extensions import DocInfo, doc # type: ignore [attr-defined]
|
||||
except ImportError:
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class DocInfo: # type: ignore [no-redef]
|
||||
""" "
|
||||
The return value of doc(), mainly to be used by tools that want to extract the
|
||||
Annotated documentation at runtime.
|
||||
"""
|
||||
|
||||
documentation: str
|
||||
"""The documentation string passed to doc()."""
|
||||
|
||||
def doc(
|
||||
documentation: str,
|
||||
) -> DocInfo:
|
||||
"""
|
||||
Add documentation to a type annotation inside of Annotated.
|
||||
|
||||
For example:
|
||||
|
||||
>>> def hi(name: Annotated[int, doc("The name of the user")]) -> None: ...
|
||||
"""
|
||||
return DocInfo(documentation)
|
147
lib/annotated_types/test_cases.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
import math
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing_extensions import Annotated
|
||||
else:
|
||||
from typing import Annotated
|
||||
|
||||
import annotated_types as at
|
||||
|
||||
|
||||
class Case(NamedTuple):
|
||||
"""
|
||||
A test case for `annotated_types`.
|
||||
"""
|
||||
|
||||
annotation: Any
|
||||
valid_cases: Iterable[Any]
|
||||
invalid_cases: Iterable[Any]
|
||||
|
||||
|
||||
def cases() -> Iterable[Case]:
|
||||
# Gt, Ge, Lt, Le
|
||||
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
|
||||
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(date(2000, 1, 1))],
|
||||
[date(2000, 1, 2), date(2000, 1, 3)],
|
||||
[date(2000, 1, 1), date(1999, 12, 31)],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(Decimal('1.123'))],
|
||||
[Decimal('1.1231'), Decimal('123')],
|
||||
[Decimal('1.123'), Decimal('0')],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
|
||||
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
|
||||
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
|
||||
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
|
||||
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
)
|
||||
|
||||
# Interval
|
||||
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
|
||||
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
|
||||
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
|
||||
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
|
||||
|
||||
# lengths
|
||||
|
||||
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
|
||||
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
|
||||
yield Case(Annotated[List[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
|
||||
yield Case(Annotated[List[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
|
||||
|
||||
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
|
||||
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
|
||||
yield Case(Annotated[List[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
|
||||
yield Case(Annotated[List[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
|
||||
|
||||
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
|
||||
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
|
||||
|
||||
yield Case(Annotated[Dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
|
||||
yield Case(Annotated[Set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
|
||||
yield Case(Annotated[Tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
|
||||
|
||||
# Timezone
|
||||
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(timezone.utc)],
|
||||
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone('Europe/London')],
|
||||
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
|
||||
)
|
||||
|
||||
# predicate types
|
||||
|
||||
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
|
||||
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
|
||||
yield Case(at.IsDigits[str], ['123'], ['', 'ab', 'a1b2'])
|
||||
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
|
||||
|
||||
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
|
||||
|
||||
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
|
||||
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
|
||||
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
|
||||
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
|
||||
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
|
||||
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
|
||||
|
||||
# check stacked predicates
|
||||
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
|
||||
|
||||
# doc
|
||||
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
|
||||
|
||||
# custom GroupedMetadata
|
||||
class MyCustomGroupedMetadata(at.GroupedMetadata):
|
||||
def __iter__(self) -> Iterator[at.Predicate]:
|
||||
yield at.Predicate(lambda x: float(x).is_integer())
|
||||
|
||||
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])
|
608
lib/appdirs.py
|
@ -1,608 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2005-2010 ActiveState Software Inc.
|
||||
# Copyright (c) 2013 Eddy Petrișor
|
||||
|
||||
"""Utilities for determining application-specific dirs.
|
||||
|
||||
See <http://github.com/ActiveState/appdirs> for details and usage.
|
||||
"""
|
||||
# Dev Notes:
|
||||
# - MSDN on where to store app data files:
|
||||
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
|
||||
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
|
||||
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
|
||||
__version__ = "1.4.4"
|
||||
__version_info__ = tuple(int(segment) for segment in __version__.split("."))
|
||||
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
PY3 = sys.version_info[0] == 3
|
||||
|
||||
if PY3:
|
||||
unicode = str
|
||||
|
||||
if sys.platform.startswith('java'):
|
||||
import platform
|
||||
os_name = platform.java_ver()[3][0]
|
||||
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
|
||||
system = 'win32'
|
||||
elif os_name.startswith('Mac'): # "Mac OS X", etc.
|
||||
system = 'darwin'
|
||||
else: # "Linux", "SunOS", "FreeBSD", etc.
|
||||
# Setting this to "linux2" is not ideal, but only Windows or Mac
|
||||
# are actually checked for and the rest of the module expects
|
||||
# *sys.platform* style strings.
|
||||
system = 'linux2'
|
||||
else:
|
||||
system = sys.platform
|
||||
|
||||
|
||||
|
||||
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
|
||||
r"""Return full path to the user-specific data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"roaming" (boolean, default False) can be set True to use the Windows
|
||||
roaming appdata directory. That means that for users on a Windows
|
||||
network setup for roaming profiles, this user data will be
|
||||
sync'd on login. See
|
||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
||||
for a discussion of issues.
|
||||
|
||||
Typical user data directories are:
|
||||
Mac OS X: ~/Library/Application Support/<AppName>
|
||||
Unix: ~/.local/share/<AppName> # or in $XDG_DATA_HOME, if defined
|
||||
Win XP (not roaming): C:\Documents and Settings\<username>\Application Data\<AppAuthor>\<AppName>
|
||||
Win XP (roaming): C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>
|
||||
Win 7 (not roaming): C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>
|
||||
Win 7 (roaming): C:\Users\<username>\AppData\Roaming\<AppAuthor>\<AppName>
|
||||
|
||||
For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
|
||||
That means, by default "~/.local/share/<AppName>".
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
|
||||
path = os.path.normpath(_get_win_folder(const))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('~/Library/Application Support/')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
|
||||
r"""Return full path to the user-shared data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"multipath" is an optional parameter only applicable to *nix
|
||||
which indicates that the entire list of data dirs should be
|
||||
returned. By default, the first item from XDG_DATA_DIRS is
|
||||
returned, or '/usr/local/share/<AppName>',
|
||||
if XDG_DATA_DIRS is not set
|
||||
|
||||
Typical site data directories are:
|
||||
Mac OS X: /Library/Application Support/<AppName>
|
||||
Unix: /usr/local/share/<AppName> or /usr/share/<AppName>
|
||||
Win XP: C:\Documents and Settings\All Users\Application Data\<AppAuthor>\<AppName>
|
||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
||||
Win 7: C:\ProgramData\<AppAuthor>\<AppName> # Hidden, but writeable on Win 7.
|
||||
|
||||
For Unix, this is using the $XDG_DATA_DIRS[0] default.
|
||||
|
||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('/Library/Application Support')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
# XDG default for $XDG_DATA_DIRS
|
||||
# only first, if multipath is False
|
||||
path = os.getenv('XDG_DATA_DIRS',
|
||||
os.pathsep.join(['/usr/local/share', '/usr/share']))
|
||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
||||
if appname:
|
||||
if version:
|
||||
appname = os.path.join(appname, version)
|
||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
||||
|
||||
if multipath:
|
||||
path = os.pathsep.join(pathlist)
|
||||
else:
|
||||
path = pathlist[0]
|
||||
return path
|
||||
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
|
||||
r"""Return full path to the user-specific config dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"roaming" (boolean, default False) can be set True to use the Windows
|
||||
roaming appdata directory. That means that for users on a Windows
|
||||
network setup for roaming profiles, this user data will be
|
||||
sync'd on login. See
|
||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
||||
for a discussion of issues.
|
||||
|
||||
Typical user config directories are:
|
||||
Mac OS X: same as user_data_dir
|
||||
Unix: ~/.config/<AppName> # or in $XDG_CONFIG_HOME, if defined
|
||||
Win *: same as user_data_dir
|
||||
|
||||
For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
|
||||
That means, by default "~/.config/<AppName>".
|
||||
"""
|
||||
if system in ["win32", "darwin"]:
|
||||
path = user_data_dir(appname, appauthor, None, roaming)
|
||||
else:
|
||||
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
|
||||
r"""Return full path to the user-shared data dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"multipath" is an optional parameter only applicable to *nix
|
||||
which indicates that the entire list of config dirs should be
|
||||
returned. By default, the first item from XDG_CONFIG_DIRS is
|
||||
returned, or '/etc/xdg/<AppName>', if XDG_CONFIG_DIRS is not set
|
||||
|
||||
Typical site config directories are:
|
||||
Mac OS X: same as site_data_dir
|
||||
Unix: /etc/xdg/<AppName> or $XDG_CONFIG_DIRS[i]/<AppName> for each value in
|
||||
$XDG_CONFIG_DIRS
|
||||
Win *: same as site_data_dir
|
||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
||||
|
||||
For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
|
||||
|
||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
||||
"""
|
||||
if system in ["win32", "darwin"]:
|
||||
path = site_data_dir(appname, appauthor)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
else:
|
||||
# XDG default for $XDG_CONFIG_DIRS
|
||||
# only first, if multipath is False
|
||||
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
|
||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
||||
if appname:
|
||||
if version:
|
||||
appname = os.path.join(appname, version)
|
||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
||||
|
||||
if multipath:
|
||||
path = os.pathsep.join(pathlist)
|
||||
else:
|
||||
path = pathlist[0]
|
||||
return path
|
||||
|
||||
|
||||
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
|
||||
r"""Return full path to the user-specific cache dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"opinion" (boolean) can be False to disable the appending of
|
||||
"Cache" to the base app data dir for Windows. See
|
||||
discussion below.
|
||||
|
||||
Typical user cache directories are:
|
||||
Mac OS X: ~/Library/Caches/<AppName>
|
||||
Unix: ~/.cache/<AppName> (XDG default)
|
||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
|
||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
|
||||
|
||||
On Windows the only suggestion in the MSDN docs is that local settings go in
|
||||
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
|
||||
app data dir (the default returned by `user_data_dir` above). Apps typically
|
||||
put cache data somewhere *under* the given dir here. Some examples:
|
||||
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
|
||||
...\Acme\SuperApp\Cache\1.0
|
||||
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
|
||||
This can be disabled with the `opinion=False` option.
|
||||
"""
|
||||
if system == "win32":
|
||||
if appauthor is None:
|
||||
appauthor = appname
|
||||
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
|
||||
if appname:
|
||||
if appauthor is not False:
|
||||
path = os.path.join(path, appauthor, appname)
|
||||
else:
|
||||
path = os.path.join(path, appname)
|
||||
if opinion:
|
||||
path = os.path.join(path, "Cache")
|
||||
elif system == 'darwin':
|
||||
path = os.path.expanduser('~/Library/Caches')
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
else:
|
||||
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def user_state_dir(appname=None, appauthor=None, version=None, roaming=False):
|
||||
r"""Return full path to the user-specific state dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"roaming" (boolean, default False) can be set True to use the Windows
|
||||
roaming appdata directory. That means that for users on a Windows
|
||||
network setup for roaming profiles, this user data will be
|
||||
sync'd on login. See
|
||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
||||
for a discussion of issues.
|
||||
|
||||
Typical user state directories are:
|
||||
Mac OS X: same as user_data_dir
|
||||
Unix: ~/.local/state/<AppName> # or in $XDG_STATE_HOME, if defined
|
||||
Win *: same as user_data_dir
|
||||
|
||||
For Unix, we follow this Debian proposal <https://wiki.debian.org/XDGBaseDirectorySpecification#state>
|
||||
to extend the XDG spec and support $XDG_STATE_HOME.
|
||||
|
||||
That means, by default "~/.local/state/<AppName>".
|
||||
"""
|
||||
if system in ["win32", "darwin"]:
|
||||
path = user_data_dir(appname, appauthor, None, roaming)
|
||||
else:
|
||||
path = os.getenv('XDG_STATE_HOME', os.path.expanduser("~/.local/state"))
|
||||
if appname:
|
||||
path = os.path.join(path, appname)
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
|
||||
r"""Return full path to the user-specific log dir for this application.
|
||||
|
||||
"appname" is the name of application.
|
||||
If None, just the system directory is returned.
|
||||
"appauthor" (only used on Windows) is the name of the
|
||||
appauthor or distributing body for this application. Typically
|
||||
it is the owning company name. This falls back to appname. You may
|
||||
pass False to disable it.
|
||||
"version" is an optional version path element to append to the
|
||||
path. You might want to use this if you want multiple versions
|
||||
of your app to be able to run independently. If used, this
|
||||
would typically be "<major>.<minor>".
|
||||
Only applied when appname is present.
|
||||
"opinion" (boolean) can be False to disable the appending of
|
||||
"Logs" to the base app data dir for Windows, and "log" to the
|
||||
base cache dir for Unix. See discussion below.
|
||||
|
||||
Typical user log directories are:
|
||||
Mac OS X: ~/Library/Logs/<AppName>
|
||||
Unix: ~/.cache/<AppName>/log # or under $XDG_CACHE_HOME if defined
|
||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Logs
|
||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Logs
|
||||
|
||||
On Windows the only suggestion in the MSDN docs is that local settings
|
||||
go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
|
||||
examples of what some windows apps use for a logs dir.)
|
||||
|
||||
OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
|
||||
value for Windows and appends "log" to the user cache dir for Unix.
|
||||
This can be disabled with the `opinion=False` option.
|
||||
"""
|
||||
if system == "darwin":
|
||||
path = os.path.join(
|
||||
os.path.expanduser('~/Library/Logs'),
|
||||
appname)
|
||||
elif system == "win32":
|
||||
path = user_data_dir(appname, appauthor, version)
|
||||
version = False
|
||||
if opinion:
|
||||
path = os.path.join(path, "Logs")
|
||||
else:
|
||||
path = user_cache_dir(appname, appauthor, version)
|
||||
version = False
|
||||
if opinion:
|
||||
path = os.path.join(path, "log")
|
||||
if appname and version:
|
||||
path = os.path.join(path, version)
|
||||
return path
|
||||
|
||||
|
||||
class AppDirs(object):
|
||||
"""Convenience wrapper for getting application dirs."""
|
||||
def __init__(self, appname=None, appauthor=None, version=None,
|
||||
roaming=False, multipath=False):
|
||||
self.appname = appname
|
||||
self.appauthor = appauthor
|
||||
self.version = version
|
||||
self.roaming = roaming
|
||||
self.multipath = multipath
|
||||
|
||||
@property
|
||||
def user_data_dir(self):
|
||||
return user_data_dir(self.appname, self.appauthor,
|
||||
version=self.version, roaming=self.roaming)
|
||||
|
||||
@property
|
||||
def site_data_dir(self):
|
||||
return site_data_dir(self.appname, self.appauthor,
|
||||
version=self.version, multipath=self.multipath)
|
||||
|
||||
@property
|
||||
def user_config_dir(self):
|
||||
return user_config_dir(self.appname, self.appauthor,
|
||||
version=self.version, roaming=self.roaming)
|
||||
|
||||
@property
|
||||
def site_config_dir(self):
|
||||
return site_config_dir(self.appname, self.appauthor,
|
||||
version=self.version, multipath=self.multipath)
|
||||
|
||||
@property
|
||||
def user_cache_dir(self):
|
||||
return user_cache_dir(self.appname, self.appauthor,
|
||||
version=self.version)
|
||||
|
||||
@property
|
||||
def user_state_dir(self):
|
||||
return user_state_dir(self.appname, self.appauthor,
|
||||
version=self.version)
|
||||
|
||||
@property
|
||||
def user_log_dir(self):
|
||||
return user_log_dir(self.appname, self.appauthor,
|
||||
version=self.version)
|
||||
|
||||
|
||||
#---- internal support stuff
|
||||
|
||||
def _get_win_folder_from_registry(csidl_name):
|
||||
"""This is a fallback technique at best. I'm not sure if using the
|
||||
registry for this guarantees us the correct answer for all CSIDL_*
|
||||
names.
|
||||
"""
|
||||
if PY3:
|
||||
import winreg as _winreg
|
||||
else:
|
||||
import _winreg
|
||||
|
||||
shell_folder_name = {
|
||||
"CSIDL_APPDATA": "AppData",
|
||||
"CSIDL_COMMON_APPDATA": "Common AppData",
|
||||
"CSIDL_LOCAL_APPDATA": "Local AppData",
|
||||
}[csidl_name]
|
||||
|
||||
key = _winreg.OpenKey(
|
||||
_winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
|
||||
)
|
||||
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
|
||||
return dir
|
||||
|
||||
|
||||
def _get_win_folder_with_pywin32(csidl_name):
|
||||
from win32com.shell import shellcon, shell
|
||||
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
|
||||
# Try to make this a unicode path because SHGetFolderPath does
|
||||
# not return unicode strings when there is unicode data in the
|
||||
# path.
|
||||
try:
|
||||
dir = unicode(dir)
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in dir:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
try:
|
||||
import win32api
|
||||
dir = win32api.GetShortPathName(dir)
|
||||
except ImportError:
|
||||
pass
|
||||
except UnicodeError:
|
||||
pass
|
||||
return dir
|
||||
|
||||
|
||||
def _get_win_folder_with_ctypes(csidl_name):
|
||||
import ctypes
|
||||
|
||||
csidl_const = {
|
||||
"CSIDL_APPDATA": 26,
|
||||
"CSIDL_COMMON_APPDATA": 35,
|
||||
"CSIDL_LOCAL_APPDATA": 28,
|
||||
}[csidl_name]
|
||||
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in buf:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
buf2 = ctypes.create_unicode_buffer(1024)
|
||||
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
|
||||
buf = buf2
|
||||
|
||||
return buf.value
|
||||
|
||||
def _get_win_folder_with_jna(csidl_name):
|
||||
import array
|
||||
from com.sun import jna
|
||||
from com.sun.jna.platform import win32
|
||||
|
||||
buf_size = win32.WinDef.MAX_PATH * 2
|
||||
buf = array.zeros('c', buf_size)
|
||||
shell = win32.Shell32.INSTANCE
|
||||
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
|
||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
||||
|
||||
# Downgrade to short path name if have highbit chars. See
|
||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
||||
has_high_char = False
|
||||
for c in dir:
|
||||
if ord(c) > 255:
|
||||
has_high_char = True
|
||||
break
|
||||
if has_high_char:
|
||||
buf = array.zeros('c', buf_size)
|
||||
kernel = win32.Kernel32.INSTANCE
|
||||
if kernel.GetShortPathName(dir, buf, buf_size):
|
||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
||||
|
||||
return dir
|
||||
|
||||
if system == "win32":
|
||||
try:
|
||||
import win32com.shell
|
||||
_get_win_folder = _get_win_folder_with_pywin32
|
||||
except ImportError:
|
||||
try:
|
||||
from ctypes import windll
|
||||
_get_win_folder = _get_win_folder_with_ctypes
|
||||
except ImportError:
|
||||
try:
|
||||
import com.sun.jna
|
||||
_get_win_folder = _get_win_folder_with_jna
|
||||
except ImportError:
|
||||
_get_win_folder = _get_win_folder_from_registry
|
||||
|
||||
|
||||
#---- self test code
|
||||
|
||||
if __name__ == "__main__":
|
||||
appname = "MyApp"
|
||||
appauthor = "MyCompany"
|
||||
|
||||
props = ("user_data_dir",
|
||||
"user_config_dir",
|
||||
"user_cache_dir",
|
||||
"user_state_dir",
|
||||
"user_log_dir",
|
||||
"site_data_dir",
|
||||
"site_config_dir")
|
||||
|
||||
print("-- app dirs %s --" % __version__)
|
||||
|
||||
print("-- app dirs (with optional 'version')")
|
||||
dirs = AppDirs(appname, appauthor, version="1.0")
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (without optional 'version')")
|
||||
dirs = AppDirs(appname, appauthor)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (without optional 'appauthor')")
|
||||
dirs = AppDirs(appname)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
||||
|
||||
print("\n-- app dirs (with disabled 'appauthor')")
|
||||
dirs = AppDirs(appname, appauthor=False)
|
||||
for prop in props:
|
||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
|
@ -3,13 +3,9 @@ from __future__ import absolute_import
|
|||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||
from apscheduler.util import iscoroutinefunction_partial
|
||||
|
||||
try:
|
||||
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||
except ImportError:
|
||||
run_coroutine_job = None
|
||||
|
||||
|
||||
class AsyncIOExecutor(BaseExecutor):
|
||||
"""
|
||||
|
@ -46,11 +42,8 @@ class AsyncIOExecutor(BaseExecutor):
|
|||
self._run_job_success(job.id, events)
|
||||
|
||||
if iscoroutinefunction_partial(job.func):
|
||||
if run_coroutine_job is not None:
|
||||
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f = self._eventloop.create_task(coro)
|
||||
else:
|
||||
raise Exception('Executing coroutine based jobs is not supported with Trollius')
|
||||
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f = self._eventloop.create_task(coro)
|
||||
else:
|
||||
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
|
|
|
@ -106,7 +106,7 @@ class MongoDBJobStore(BaseJobStore):
|
|||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
self.collection.remove()
|
||||
self.collection.delete_many({})
|
||||
|
||||
def shutdown(self):
|
||||
self.client.close()
|
||||
|
@ -133,7 +133,7 @@ class MongoDBJobStore(BaseJobStore):
|
|||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
self.collection.remove({'_id': {'$in': failed_job_ids}})
|
||||
self.collection.delete_many({'_id': {'$in': failed_job_ids}})
|
||||
|
||||
return jobs
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||
# 25 = precision that translates to an 8-byte float
|
||||
self.jobs_t = Table(
|
||||
tablename, metadata,
|
||||
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
|
||||
Column('id', Unicode(191), primary_key=True),
|
||||
Column('next_run_time', Float(25), index=True),
|
||||
Column('job_state', LargeBinary, nullable=False),
|
||||
schema=tableschema
|
||||
|
@ -68,20 +68,22 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||
self.jobs_t.create(self.engine, True)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
selectable = select([self.jobs_t.c.job_state]).where(self.jobs_t.c.id == job_id)
|
||||
job_state = self.engine.execute(selectable).scalar()
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
selectable = select(self.jobs_t.c.job_state).where(self.jobs_t.c.id == job_id)
|
||||
with self.engine.begin() as connection:
|
||||
job_state = connection.execute(selectable).scalar()
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||
|
||||
def get_next_run_time(self):
|
||||
selectable = select([self.jobs_t.c.next_run_time]).\
|
||||
selectable = select(self.jobs_t.c.next_run_time).\
|
||||
where(self.jobs_t.c.next_run_time != null()).\
|
||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||
next_run_time = self.engine.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
with self.engine.begin() as connection:
|
||||
next_run_time = connection.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
|
||||
def get_all_jobs(self):
|
||||
jobs = self._get_jobs()
|
||||
|
@ -94,29 +96,33 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
})
|
||||
try:
|
||||
self.engine.execute(insert)
|
||||
except IntegrityError:
|
||||
raise ConflictingIdError(job.id)
|
||||
with self.engine.begin() as connection:
|
||||
try:
|
||||
connection.execute(insert)
|
||||
except IntegrityError:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
update = self.jobs_t.update().values(**{
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
}).where(self.jobs_t.c.id == job.id)
|
||||
result = self.engine.execute(update)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job.id)
|
||||
with self.engine.begin() as connection:
|
||||
result = connection.execute(update)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
||||
result = self.engine.execute(delete)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job_id)
|
||||
with self.engine.begin() as connection:
|
||||
result = connection.execute(delete)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
delete = self.jobs_t.delete()
|
||||
self.engine.execute(delete)
|
||||
with self.engine.begin() as connection:
|
||||
connection.execute(delete)
|
||||
|
||||
def shutdown(self):
|
||||
self.engine.dispose()
|
||||
|
@ -132,21 +138,22 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||
|
||||
def _get_jobs(self, *conditions):
|
||||
jobs = []
|
||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).\
|
||||
selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state).\
|
||||
order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = selectable.where(and_(*conditions)) if conditions else selectable
|
||||
failed_job_ids = set()
|
||||
for row in self.engine.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||
failed_job_ids.add(row.id)
|
||||
with self.engine.begin() as connection:
|
||||
for row in connection.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||
failed_job_ids.add(row.id)
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||
self.engine.execute(delete)
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||
connection.execute(delete)
|
||||
|
||||
return jobs
|
||||
|
||||
|
|
|
@ -1,18 +1,10 @@
|
|||
from __future__ import absolute_import
|
||||
import asyncio
|
||||
from functools import wraps, partial
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.util import maybe_ref
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
except ImportError: # pragma: nocover
|
||||
try:
|
||||
import trollius as asyncio
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
|
||||
|
||||
|
||||
def run_in_event_loop(func):
|
||||
@wraps(func)
|
||||
|
|
|
@ -191,12 +191,11 @@ class BaseScheduler(six.with_metaclass(ABCMeta)):
|
|||
self.state = STATE_STOPPED
|
||||
|
||||
# Shut down all executors
|
||||
with self._executors_lock:
|
||||
with self._executors_lock, self._jobstores_lock:
|
||||
for executor in six.itervalues(self._executors):
|
||||
executor.shutdown(wait)
|
||||
|
||||
# Shut down all job stores
|
||||
with self._jobstores_lock:
|
||||
# Shut down all job stores
|
||||
for jobstore in six.itervalues(self._jobstores):
|
||||
jobstore.shutdown()
|
||||
|
||||
|
|
|
@ -9,13 +9,16 @@ except (ImportError, RuntimeError): # pragma: nocover
|
|||
from PyQt4.QtCore import QObject, QTimer
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide2.QtCore import QObject, QTimer # noqa
|
||||
from PySide6.QtCore import QObject, QTimer # noqa
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide.QtCore import QObject, QTimer # noqa
|
||||
from PySide2.QtCore import QObject, QTimer # noqa
|
||||
except ImportError:
|
||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4, PySide2 '
|
||||
'or PySide installed')
|
||||
try:
|
||||
from PySide.QtCore import QObject, QTimer # noqa
|
||||
except ImportError:
|
||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4, PySide6, PySide2 '
|
||||
'or PySide installed')
|
||||
|
||||
|
||||
class QtScheduler(BaseScheduler):
|
||||
|
@ -30,7 +33,7 @@ class QtScheduler(BaseScheduler):
|
|||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
wait_time = min(wait_seconds * 1000, 2147483647)
|
||||
wait_time = min(int(wait_seconds * 1000), 2147483647)
|
||||
self._timer = QTimer.singleShot(wait_time, self._process_jobs)
|
||||
|
||||
def _stop_timer(self):
|
||||
|
|
|
@ -6,7 +6,8 @@ import six
|
|||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.cron.fields import (
|
||||
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
|
||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
||||
from apscheduler.util import (
|
||||
datetime_ceil, convert_to_datetime, datetime_repr, astimezone, localize, normalize)
|
||||
|
||||
|
||||
class CronTrigger(BaseTrigger):
|
||||
|
@ -143,7 +144,7 @@ class CronTrigger(BaseTrigger):
|
|||
i += 1
|
||||
|
||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||
return self.timezone.normalize(dateval + difference), fieldnum
|
||||
return normalize(dateval + difference), fieldnum
|
||||
|
||||
def _set_field_value(self, dateval, fieldnum, new_value):
|
||||
values = {}
|
||||
|
@ -156,7 +157,7 @@ class CronTrigger(BaseTrigger):
|
|||
else:
|
||||
values[field.name] = new_value
|
||||
|
||||
return self.timezone.localize(datetime(**values))
|
||||
return localize(datetime(**values), self.timezone)
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
if previous_fire_time:
|
||||
|
|
|
@ -4,7 +4,9 @@ from math import ceil
|
|||
from tzlocal import get_localzone
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
|
||||
from apscheduler.util import (
|
||||
convert_to_datetime, normalize, timedelta_seconds, datetime_repr,
|
||||
astimezone)
|
||||
|
||||
|
||||
class IntervalTrigger(BaseTrigger):
|
||||
|
@ -63,7 +65,7 @@ class IntervalTrigger(BaseTrigger):
|
|||
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
|
||||
|
||||
if not self.end_date or next_fire_time <= self.end_date:
|
||||
return self.timezone.normalize(next_fire_time)
|
||||
return normalize(next_fire_time)
|
||||
|
||||
def __getstate__(self):
|
||||
return {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import division
|
||||
|
||||
from asyncio import iscoroutinefunction
|
||||
from datetime import date, datetime, time, timedelta, tzinfo
|
||||
from calendar import timegm
|
||||
from functools import partial
|
||||
|
@ -22,19 +23,10 @@ try:
|
|||
except ImportError:
|
||||
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||
|
||||
try:
|
||||
from asyncio import iscoroutinefunction
|
||||
except ImportError:
|
||||
try:
|
||||
from trollius import iscoroutinefunction
|
||||
except ImportError:
|
||||
def iscoroutinefunction(func):
|
||||
return False
|
||||
|
||||
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
||||
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
|
||||
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args',
|
||||
'TIMEOUT_MAX')
|
||||
'normalize', 'localize', 'TIMEOUT_MAX')
|
||||
|
||||
|
||||
class _Undefined(object):
|
||||
|
@ -90,9 +82,7 @@ def astimezone(obj):
|
|||
if isinstance(obj, six.string_types):
|
||||
return timezone(obj)
|
||||
if isinstance(obj, tzinfo):
|
||||
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
|
||||
raise TypeError('Only timezones from the pytz library are supported')
|
||||
if obj.zone == 'local':
|
||||
if obj.tzname(None) == 'local':
|
||||
raise ValueError(
|
||||
'Unable to determine the name of the local timezone -- you must explicitly '
|
||||
'specify the name of the local timezone. Please refrain from using timezones like '
|
||||
|
@ -162,11 +152,7 @@ def convert_to_datetime(input, tz, arg_name):
|
|||
if isinstance(tz, six.string_types):
|
||||
tz = timezone(tz)
|
||||
|
||||
try:
|
||||
return tz.localize(datetime_, is_dst=None)
|
||||
except AttributeError:
|
||||
raise TypeError(
|
||||
'Only pytz timezones are supported (need the localize() and normalize() methods)')
|
||||
return localize(datetime_, tz)
|
||||
|
||||
|
||||
def datetime_to_utc_timestamp(timeval):
|
||||
|
@ -431,3 +417,14 @@ def iscoroutinefunction_partial(f):
|
|||
# The asyncio version of iscoroutinefunction includes testing for @coroutine
|
||||
# decorations vs. the inspect version which does not.
|
||||
return iscoroutinefunction(f)
|
||||
|
||||
|
||||
def normalize(dt):
|
||||
return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
|
||||
|
||||
|
||||
def localize(dt, tzinfo):
|
||||
if hasattr(tzinfo, 'localize'):
|
||||
return tzinfo.localize(dt)
|
||||
|
||||
return normalize(dt.replace(tzinfo=tzinfo))
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "1.2.1"
|
||||
__version__ = "1.3.0"
|
||||
|
|
|
@ -168,9 +168,9 @@ class Arrow:
|
|||
isinstance(tzinfo, dt_tzinfo)
|
||||
and hasattr(tzinfo, "localize")
|
||||
and hasattr(tzinfo, "zone")
|
||||
and tzinfo.zone # type: ignore[attr-defined]
|
||||
and tzinfo.zone
|
||||
):
|
||||
tzinfo = parser.TzinfoParser.parse(tzinfo.zone) # type: ignore[attr-defined]
|
||||
tzinfo = parser.TzinfoParser.parse(tzinfo.zone)
|
||||
elif isinstance(tzinfo, str):
|
||||
tzinfo = parser.TzinfoParser.parse(tzinfo)
|
||||
|
||||
|
@ -495,7 +495,7 @@ class Arrow:
|
|||
yield current
|
||||
|
||||
values = [getattr(current, f) for f in cls._ATTRS]
|
||||
current = cls(*values, tzinfo=tzinfo).shift( # type: ignore
|
||||
current = cls(*values, tzinfo=tzinfo).shift( # type: ignore[misc]
|
||||
**{frame_relative: relative_steps}
|
||||
)
|
||||
|
||||
|
@ -578,7 +578,7 @@ class Arrow:
|
|||
for _ in range(3 - len(values)):
|
||||
values.append(1)
|
||||
|
||||
floor = self.__class__(*values, tzinfo=self.tzinfo) # type: ignore
|
||||
floor = self.__class__(*values, tzinfo=self.tzinfo) # type: ignore[misc]
|
||||
|
||||
if frame_absolute == "week":
|
||||
# if week_start is greater than self.isoweekday() go back one week by setting delta = 7
|
||||
|
@ -759,7 +759,7 @@ class Arrow:
|
|||
>>> start = datetime(2013, 5, 5, 12, 30)
|
||||
>>> end = datetime(2013, 5, 5, 17, 15)
|
||||
>>> for r in arrow.Arrow.interval('hour', start, end, 2):
|
||||
... print r
|
||||
... print(r)
|
||||
...
|
||||
(<Arrow [2013-05-05T12:00:00+00:00]>, <Arrow [2013-05-05T13:59:59.999999+00:00]>)
|
||||
(<Arrow [2013-05-05T14:00:00+00:00]>, <Arrow [2013-05-05T15:59:59.999999+00:00]>)
|
||||
|
@ -792,7 +792,6 @@ class Arrow:
|
|||
return self._datetime.isoformat()
|
||||
|
||||
def __format__(self, formatstr: str) -> str:
|
||||
|
||||
if len(formatstr) > 0:
|
||||
return self.format(formatstr)
|
||||
|
||||
|
@ -804,7 +803,6 @@ class Arrow:
|
|||
# attributes and properties
|
||||
|
||||
def __getattr__(self, name: str) -> int:
|
||||
|
||||
if name == "week":
|
||||
return self.isocalendar()[1]
|
||||
|
||||
|
@ -965,7 +963,6 @@ class Arrow:
|
|||
absolute_kwargs = {}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
|
||||
if key in self._ATTRS:
|
||||
absolute_kwargs[key] = value
|
||||
elif key in ["week", "quarter"]:
|
||||
|
@ -1022,7 +1019,6 @@ class Arrow:
|
|||
additional_attrs = ["weeks", "quarters", "weekday"]
|
||||
|
||||
for key, value in kwargs.items():
|
||||
|
||||
if key in self._ATTRS_PLURAL or key in additional_attrs:
|
||||
relative_kwargs[key] = value
|
||||
else:
|
||||
|
@ -1259,11 +1255,10 @@ class Arrow:
|
|||
)
|
||||
|
||||
if trunc(abs(delta)) != 1:
|
||||
granularity += "s" # type: ignore
|
||||
granularity += "s" # type: ignore[assignment]
|
||||
return locale.describe(granularity, delta, only_distance=only_distance)
|
||||
|
||||
else:
|
||||
|
||||
if not granularity:
|
||||
raise ValueError(
|
||||
"Empty granularity list provided. "
|
||||
|
@ -1314,7 +1309,7 @@ class Arrow:
|
|||
|
||||
def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow":
|
||||
"""Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, that represents
|
||||
the time difference relative to the attrbiutes of the
|
||||
the time difference relative to the attributes of the
|
||||
:class:`Arrow <arrow.arrow.Arrow>` object.
|
||||
|
||||
:param timestring: a ``str`` representing a humanized relative time.
|
||||
|
@ -1367,7 +1362,6 @@ class Arrow:
|
|||
|
||||
# Search input string for each time unit within locale
|
||||
for unit, unit_object in locale_obj.timeframes.items():
|
||||
|
||||
# Need to check the type of unit_object to create the correct dictionary
|
||||
if isinstance(unit_object, Mapping):
|
||||
strings_to_search = unit_object
|
||||
|
@ -1378,13 +1372,12 @@ class Arrow:
|
|||
# Needs to cycle all through strings as some locales have strings that
|
||||
# could overlap in a regex match, since input validation isn't being performed.
|
||||
for time_delta, time_string in strings_to_search.items():
|
||||
|
||||
# Replace {0} with regex \d representing digits
|
||||
search_string = str(time_string)
|
||||
search_string = search_string.format(r"\d+")
|
||||
|
||||
# Create search pattern and find within string
|
||||
pattern = re.compile(fr"{search_string}")
|
||||
pattern = re.compile(rf"(^|\b|\d){search_string}")
|
||||
match = pattern.search(input_string)
|
||||
|
||||
# If there is no match continue to next iteration
|
||||
|
@ -1419,19 +1412,19 @@ class Arrow:
|
|||
# Assert error if string does not modify any units
|
||||
if not any([True for k, v in unit_visited.items() if v]):
|
||||
raise ValueError(
|
||||
"Input string not valid. Note: Some locales do not support the week granulairty in Arrow. "
|
||||
"Input string not valid. Note: Some locales do not support the week granularity in Arrow. "
|
||||
"If you are attempting to use the week granularity on an unsupported locale, this could be the cause of this error."
|
||||
)
|
||||
|
||||
# Sign logic
|
||||
future_string = locale_obj.future
|
||||
future_string = future_string.format(".*")
|
||||
future_pattern = re.compile(fr"^{future_string}$")
|
||||
future_pattern = re.compile(rf"^{future_string}$")
|
||||
future_pattern_match = future_pattern.findall(input_string)
|
||||
|
||||
past_string = locale_obj.past
|
||||
past_string = past_string.format(".*")
|
||||
past_pattern = re.compile(fr"^{past_string}$")
|
||||
past_pattern = re.compile(rf"^{past_string}$")
|
||||
past_pattern_match = past_pattern.findall(input_string)
|
||||
|
||||
# If a string contains the now unit, there will be no relative units, hence the need to check if the now unit
|
||||
|
@ -1718,7 +1711,6 @@ class Arrow:
|
|||
# math
|
||||
|
||||
def __add__(self, other: Any) -> "Arrow":
|
||||
|
||||
if isinstance(other, (timedelta, relativedelta)):
|
||||
return self.fromdatetime(self._datetime + other, self._datetime.tzinfo)
|
||||
|
||||
|
@ -1736,7 +1728,6 @@ class Arrow:
|
|||
pass # pragma: no cover
|
||||
|
||||
def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]:
|
||||
|
||||
if isinstance(other, (timedelta, relativedelta)):
|
||||
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
|
||||
|
||||
|
@ -1749,7 +1740,6 @@ class Arrow:
|
|||
return NotImplemented
|
||||
|
||||
def __rsub__(self, other: Any) -> timedelta:
|
||||
|
||||
if isinstance(other, dt_datetime):
|
||||
return other - self._datetime
|
||||
|
||||
|
@ -1758,42 +1748,36 @@ class Arrow:
|
|||
# comparisons
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return False
|
||||
|
||||
return self._datetime == self._get_datetime(other)
|
||||
|
||||
def __ne__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return True
|
||||
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __gt__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return NotImplemented
|
||||
|
||||
return self._datetime > self._get_datetime(other)
|
||||
|
||||
def __ge__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return NotImplemented
|
||||
|
||||
return self._datetime >= self._get_datetime(other)
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return NotImplemented
|
||||
|
||||
return self._datetime < self._get_datetime(other)
|
||||
|
||||
def __le__(self, other: Any) -> bool:
|
||||
|
||||
if not isinstance(other, (Arrow, dt_datetime)):
|
||||
return NotImplemented
|
||||
|
||||
|
@ -1865,7 +1849,6 @@ class Arrow:
|
|||
def _get_iteration_params(cls, end: Any, limit: Optional[int]) -> Tuple[Any, int]:
|
||||
"""Sets default end and limit values for range method."""
|
||||
if end is None:
|
||||
|
||||
if limit is None:
|
||||
raise ValueError("One of 'end' or 'limit' is required.")
|
||||
|
||||
|
|