Compare commits
770 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2ac198831 | |||
|
|
abd263f127 | ||
|
|
9c2d4ac6a7 | ||
|
|
4d950ac17e | ||
|
|
5e688c104c | ||
|
|
4ce0819c68 | ||
|
|
f7d9d0555c | ||
|
|
b754182e0d | ||
|
|
7b3a607b5e | ||
|
|
0e47e9c07f | ||
|
|
c140e5a163 | ||
|
|
0bc379bf42 | ||
|
|
9f0a3a0d9f | ||
|
|
355cf9a36c | ||
|
|
d9792e99c1 | ||
|
|
6cb7de7fb3 | ||
|
|
cb0860cddf | ||
|
|
6edeb59b16 | ||
|
|
a245aa73d4 | ||
|
|
08be9bdb4e | ||
|
|
2ee2181211 | ||
|
|
59e1c7d6f4 | ||
|
|
c2aca6e3a0 | ||
| 5f39da9273 | |||
| e16d69737e | |||
|
|
e6e2af38d3 | ||
|
|
975697728e | ||
|
|
a2789c9dba | ||
|
|
096197cf81 | ||
|
|
f9d577f596 | ||
|
|
429356a217 | ||
|
|
636e2aefde | ||
|
|
e830589e38 | ||
|
|
933ba62f39 | ||
|
|
93f7aa4457 | ||
|
|
3ef45c42fb | ||
|
|
071e5978aa | ||
|
|
fba5102705 | ||
|
|
138e308c32 | ||
|
|
fa8f1a0c8e | ||
|
|
7325c3b4d3 | ||
|
|
3e5ddb2aff | ||
|
|
d1ebbde778 | ||
|
|
8a042c5714 | ||
|
|
7a2c252bbc | ||
|
|
7d212174a6 | ||
|
|
685b35cb23 | ||
|
|
1175a54266 | ||
|
|
d38064e1c0 | ||
|
|
59a57a740f | ||
|
|
f319851753 | ||
|
|
ce424977c0 | ||
|
|
62c6b0d4cb | ||
|
|
7685570da9 | ||
|
|
ff0c488968 | ||
|
|
ebc6c8df4d | ||
|
|
06a2c3ab12 | ||
|
|
e877da1825 | ||
|
|
0b744b1179 | ||
|
|
1041f21724 | ||
|
|
73012f7976 | ||
|
|
a1b4cece7d | ||
|
|
ac4cd50929 | ||
|
|
bb5b3846a6 | ||
|
|
3a11fbcf18 | ||
|
|
a8da740ada | ||
|
|
082ec0f592 | ||
|
|
d49cad0bc0 | ||
|
|
75d8676de5 | ||
|
|
8228bc8996 | ||
|
|
8081990895 | ||
|
|
eb2d5419f9 | ||
|
|
a9ba3fcbab | ||
|
|
bcd610e2e3 | ||
|
|
217362fe14 | ||
|
|
0e0d860cd2 | ||
|
|
a8246a96cd | ||
|
|
33d28d1e7b | ||
|
|
377d7bdf47 | ||
|
|
f6eb3dd90e | ||
|
|
f632a66d60 | ||
|
|
ea0bb607b6 | ||
|
|
85e15b77d9 | ||
|
|
95055dea1d | ||
|
|
630804c5b5 | ||
|
|
6cce4183e9 | ||
|
|
35bd0d625c | ||
|
|
073d00b45b | ||
|
|
982cb13e7e | ||
|
|
6d3d4c8339 | ||
|
|
911e420c24 | ||
|
|
e69ff0325e | ||
|
|
7c1353a8aa | ||
|
|
4ce9a172b8 | ||
|
|
61a7b24084 | ||
|
|
8b2ca19f1a | ||
|
|
deafa8f4d3 | ||
|
|
c739935335 | ||
|
|
34e4a4f483 | ||
|
|
85c485509c | ||
|
|
026145e0c7 | ||
|
|
bb0cf5b547 | ||
|
|
d8acffa844 | ||
|
|
a1f1cddcbe | ||
|
|
d19612bb3c | ||
|
|
30e81961e8 | ||
|
|
a9873da802 | ||
|
|
6046e62f11 | ||
|
|
eb032619a0 | ||
|
|
d98f0a20a8 | ||
|
|
945b40249c | ||
|
|
1192642811 | ||
|
|
70772a61a9 | ||
|
|
66b3f031a5 | ||
|
|
56a9c115f8 | ||
|
|
bd10ed93af | ||
|
|
c3351f01e4 | ||
|
|
1d13384f6c | ||
|
|
598e225b00 | ||
|
|
d8090a8172 | ||
|
|
f47413686c | ||
|
|
c8a194cf32 | ||
|
|
f07ac1f322 | ||
|
|
02561b614f | ||
|
|
6ca2d02c69 | ||
|
|
300e103fed | ||
|
|
a77c0e5f32 | ||
|
|
cf644d508d | ||
|
|
452be8a22f | ||
|
|
1e816f65a5 | ||
|
|
d8496fe4c4 | ||
|
|
ca10956014 | ||
|
|
3984b84b6c | ||
|
|
493114e825 | ||
|
|
7856b6fd27 | ||
|
|
957b47680d | ||
|
|
86f6a944ff | ||
|
|
2631173a0d | ||
|
|
0dc1f810d2 | ||
|
|
0b8d19fbbe | ||
|
|
51bbe4193b | ||
|
|
d5945f6ac6 | ||
|
|
539f8824d1 | ||
|
|
539efc3f7b | ||
|
|
c0861ba796 | ||
|
|
52d308e5ae | ||
|
|
57f3afae97 | ||
|
|
80ff75debc | ||
|
|
d3a3bd2784 | ||
|
|
2dcccb804e | ||
|
|
ed7ac04d64 | ||
|
|
baf4d2bde6 | ||
|
|
ff047c5823 | ||
|
|
6c701e0c41 | ||
|
|
28ea14f2e9 | ||
|
|
f00f9b104c | ||
|
|
78d91826b1 | ||
|
|
a5be789745 | ||
|
|
028e318bd3 | ||
|
|
b2b0b0f2c8 | ||
|
|
42e73cb7e9 | ||
|
|
89c3733ce8 | ||
|
|
4be473dcea | ||
|
|
97bdc32cce | ||
|
|
d2da46854e | ||
|
|
150d3622e4 | ||
|
|
990ab29200 | ||
|
|
80d3bc84b8 | ||
|
|
b31c79431e | ||
|
|
73587ff47b | ||
|
|
53e9db2f42 | ||
|
|
b290441956 | ||
|
|
9d0d20afbd | ||
|
|
4a7d3d5fc2 | ||
|
|
32ecb05902 | ||
|
|
644ca7d0d0 | ||
|
|
7dc1b25ee7 | ||
|
|
3f28b9abc4 | ||
|
|
cf36d91da4 | ||
|
|
8c1465d3a2 | ||
|
|
bed7a4d6be | ||
|
|
23a91cca16 | ||
|
|
82c99e2cfa | ||
|
|
7d745179d4 | ||
|
|
78283e51f6 | ||
|
|
5a9ff8e331 | ||
|
|
814ddaa532 | ||
|
|
b927b5fac2 | ||
|
|
e9cf140177 | ||
|
|
9ee9de7911 | ||
|
|
53844aa0ff | ||
|
|
f527f743fc | ||
|
|
5801736955 | ||
|
|
11afc4c37d | ||
|
|
5f1427cabd | ||
|
|
27008e0f18 | ||
|
|
8898fd6e2f | ||
|
|
96d4cd50d4 | ||
|
|
7a77f31aa3 | ||
|
|
78f29e726e | ||
|
|
92e2006782 | ||
|
|
5b2edc73ec | ||
|
|
395e31ff85 | ||
|
|
dd0d828794 | ||
|
|
15bb1a2335 | ||
|
|
0a7b7880da | ||
|
|
277f324cf0 | ||
|
|
e496d5bf36 | ||
|
|
0ed6c8979e | ||
|
|
075dd1adeb | ||
|
|
027faf1949 | ||
|
|
54e049874c | ||
|
|
c4c6ba9ea4 | ||
|
|
a77bf0b8fb | ||
|
|
b9bad14df6 | ||
|
|
b1058933b8 | ||
|
|
32c7f6b29e | ||
|
|
137705450b | ||
|
|
4dc6034de8 | ||
|
|
7fa18fe38f | ||
|
|
7e34347ed7 | ||
|
|
68fa547b06 | ||
|
|
a113ed049d | ||
|
|
2f8a6a91f8 | ||
|
|
f75ed257f2 | ||
|
|
06091d1af4 | ||
|
|
fb4e550122 | ||
|
|
a74301f479 | ||
|
|
c49f417d00 | ||
|
|
a2bcd8aa30 | ||
|
|
6c34686d8d | ||
|
|
86ec6f43cb | ||
|
|
e9c7a3b99e | ||
|
|
f0ac63cd96 | ||
|
|
b1e8663ba9 | ||
|
|
2cb6bf2b98 | ||
|
|
b35c63d1c8 | ||
|
|
96b929b313 | ||
|
|
d8cddfafa0 | ||
|
|
de7d12d2c8 | ||
|
|
bc8d521853 | ||
|
|
a6c2666c82 | ||
|
|
32424e7938 | ||
|
|
ca4a3d64eb | ||
|
|
2257688c65 | ||
|
|
ea9ddc58c0 | ||
|
|
c47d375a58 | ||
|
|
36f97a5300 | ||
|
|
4adc3fadaa | ||
|
|
d9c64e7f87 | ||
|
|
9f8ae75691 | ||
|
|
7e0b88ddbd | ||
|
|
ed62f2b1f2 | ||
|
|
4e89cdaca1 | ||
|
|
b72ba8c66a | ||
|
|
fc9de568bd | ||
|
|
fe5a542e08 | ||
|
|
5d86693d98 | ||
|
|
4caefdb3fd | ||
|
|
2f843a4d40 | ||
|
|
0175609f02 | ||
|
|
f9f0dad203 | ||
|
|
fdb064ffc8 | ||
|
|
e98231c327 | ||
|
|
0888da2197 | ||
|
|
2d231ad989 | ||
|
|
19e0b7e565 | ||
|
|
fd4128c364 | ||
|
|
98f105fda0 | ||
|
|
201f2e23c7 | ||
|
|
32b257ec30 | ||
|
|
53bceb89cd | ||
|
|
04770a1e8f | ||
|
|
fbda4c76f0 | ||
|
|
85387a0a9b | ||
|
|
5ac4f6dcf9 | ||
|
|
6671f8c6fe | ||
|
|
5b77bb4649 | ||
|
|
8f6329d71d | ||
|
|
5d71ac584c | ||
|
|
534c627300 | ||
|
|
1a1e5ecd3f | ||
|
|
3e555c64d9 | ||
|
|
8d6b5f7105 | ||
|
|
4692be663e | ||
|
|
5ae0fb1b0e | ||
|
|
990d830f24 | ||
|
|
c6aa90db3e | ||
|
|
c1eed45194 | ||
|
|
31ee668311 | ||
|
|
9f60121609 | ||
|
|
a61d04f445 | ||
|
|
8ba8ada253 | ||
|
|
885b796f85 | ||
|
|
fa47806c93 | ||
|
|
5997666bad | ||
|
|
40186ce155 | ||
|
|
9cfdb35224 | ||
|
|
64860c4624 | ||
|
|
32bd2a89a3 | ||
|
|
1e6eca307b | ||
|
|
fc23fcd7c1 | ||
|
|
ab52825c71 | ||
|
|
28def30510 | ||
|
|
8f4d017180 | ||
|
|
df302ad517 | ||
|
|
357d99ac7c | ||
|
|
7e3fecc44a | ||
|
|
e0b3dd9795 | ||
|
|
6a52a2bc46 | ||
|
|
cd2b087006 | ||
|
|
ef2940142c | ||
|
|
e13b8b8827 | ||
|
|
43fce2e89a | ||
|
|
7856f0d602 | ||
|
|
bcc6ab50e2 | ||
|
|
23aa8c05ab | ||
|
|
986d898217 | ||
|
|
654c0e5e56 | ||
|
|
fed0edbf16 | ||
|
|
ede91da6f8 | ||
|
|
1f18d4291f | ||
|
|
d066e8939d | ||
|
|
e4c3435d34 | ||
|
|
fc80b180bc | ||
|
|
0d9dca99e9 | ||
|
|
2ff921cc1a | ||
|
|
e0e66fa6af | ||
|
|
110e29ec5a | ||
|
|
a2b6d622de | ||
|
|
0887789f5e | ||
|
|
3c4574f2ec | ||
|
|
62029f6164 | ||
|
|
ba986847d6 | ||
|
|
fded78ce6f | ||
|
|
8d44649a1e | ||
|
|
f4d0c51dc2 | ||
|
|
ba60db9977 | ||
|
|
0a5a7a684d | ||
|
|
7181c46ed5 | ||
|
|
afa7e69347 | ||
|
|
5050dda4d2 | ||
|
|
9ea1e16ac8 | ||
|
|
922f44f605 | ||
|
|
6ea3fc918b | ||
|
|
0c201533f0 | ||
|
|
b1e99b96c4 | ||
|
|
de1c8485ae | ||
|
|
565d1d0388 | ||
|
|
a15fea1d65 | ||
|
|
da3cf6ca27 | ||
|
|
d8dbbc6832 | ||
|
|
3563a964ef | ||
|
|
208f0c248d | ||
|
|
c0e034726a | ||
|
|
d676a7071a | ||
|
|
a681b2c944 | ||
|
|
c53701d56b | ||
|
|
a8c1f2eccc | ||
|
|
cf7ee44efc | ||
|
|
2e4a9eaaeb | ||
|
|
569a2b5694 | ||
|
|
baa84773a5 | ||
|
|
a39e642b5a | ||
|
|
e42cb49cbe | ||
|
|
b40c12efbd | ||
|
|
3c5ddad133 | ||
|
|
1ca4cb40dd | ||
|
|
678b0b9db1 | ||
|
|
e633fa41ac | ||
|
|
dac303e33b | ||
|
|
fd77d672c5 | ||
|
|
a991d9f512 | ||
|
|
674b3b54dc | ||
|
|
09948845ca | ||
|
|
0b8a0695f7 | ||
|
|
a7c95d5718 | ||
|
|
8a05597e52 | ||
|
|
145d45f800 | ||
|
|
0476b67b2f | ||
|
|
2751a828e0 | ||
|
|
a3f5dbfe07 | ||
|
|
60e09c0dd7 | ||
|
|
a23297e56a | ||
|
|
b05163632b | ||
|
|
f59be31ded | ||
|
|
2e527250de | ||
|
|
91dd378f5d | ||
|
|
f5b3d033a3 | ||
|
|
db98e3b722 | ||
|
|
586cf16fdc | ||
|
|
064d877205 | ||
|
|
fbf8cc87c9 | ||
|
|
bebabf84a0 | ||
|
|
36f169635a | ||
|
|
3f509f44a1 | ||
|
|
9bc8a8ac2b | ||
|
|
abfe98283b | ||
|
|
ded0224f51 | ||
|
|
0060a634d2 | ||
|
|
d34005e04f | ||
|
|
c734bd48b5 | ||
|
|
174c814541 | ||
|
|
993b6e9d0a | ||
|
|
273c9f7b99 | ||
|
|
822deb2867 | ||
|
|
132e2afce0 | ||
|
|
2a888f0bce | ||
|
|
f8202a7add | ||
|
|
87e8d0b775 | ||
|
|
569c00ff5f | ||
|
|
15457e1db4 | ||
|
|
0ae1eb9578 | ||
|
|
aa1d0161d3 | ||
|
|
a2d8b88337 | ||
|
|
a1b4306954 | ||
|
|
88e07031a6 | ||
|
|
cb4daa1ba1 | ||
|
|
46cfa79e89 | ||
|
|
a480f47cea | ||
|
|
4ef789e7b9 | ||
|
|
05d6d8399d | ||
|
|
8bc81d6b5e | ||
|
|
6c5cb8f07d | ||
|
|
70e79e50d6 | ||
|
|
d2c6c6cc4d | ||
|
|
ba5e22bd21 | ||
|
|
9d0bfdea44 | ||
|
|
92ae681517 | ||
|
|
3afa8c4441 | ||
|
|
547fdef9c4 | ||
|
|
152c250300 | ||
|
|
04161284a7 | ||
|
|
14d4818867 | ||
|
|
9f42e91088 | ||
|
|
79ac3b9700 | ||
|
|
7a3178896b | ||
|
|
ee95512321 | ||
|
|
55519b1855 | ||
|
|
89b99614a0 | ||
|
|
7bbd9a1a4f | ||
|
|
0dfb48593e | ||
|
|
3abe0803d6 | ||
|
|
1e0a919dc7 | ||
|
|
489e8aa4b3 | ||
|
|
5058f2f8d8 | ||
|
|
49aff9f22e | ||
|
|
775d4accf7 | ||
|
|
703a1baf8b | ||
|
|
51fdad2ae2 | ||
|
|
86545e5f99 | ||
|
|
5a0413b3e4 | ||
|
|
a0ff2b8ae4 | ||
|
|
6b6c390cb8 | ||
|
|
cd937c4844 | ||
|
|
cc1a5783fd | ||
|
|
5631caad36 | ||
|
|
d0144fb0d0 | ||
|
|
5d1870c1cf | ||
|
|
fb87460bc7 | ||
|
|
cb122cbc61 | ||
|
|
efc4e299ac | ||
|
|
848caa275b | ||
|
|
03754c56c6 | ||
|
|
471f6ff93b | ||
|
|
c53ad56a6d | ||
|
|
646439d86a | ||
|
|
ae7f65e938 | ||
|
|
b2d71a037c | ||
|
|
019ec8972f | ||
|
|
e25e411ebe | ||
|
|
911dcbac9f | ||
|
|
25195c972c | ||
|
|
304c92f733 | ||
|
|
336b9a126e | ||
|
|
01efbf5c79 | ||
|
|
866fa6a758 | ||
|
|
b8bb5e6d82 | ||
|
|
cc4b3cce55 | ||
|
|
5699562133 | ||
|
|
dae064d2bb | ||
|
|
d54e15cd15 | ||
|
|
dbdf56d494 | ||
|
|
b07cd5515a | ||
|
|
73929f2d25 | ||
|
|
acaa06749e | ||
|
|
1bd6efc0c1 | ||
|
|
936960269d | ||
|
|
5780df20ad | ||
|
|
14e5f33f2a | ||
|
|
7aa86bcec4 | ||
|
|
c77a1e85a4 | ||
|
|
093b8cb6df | ||
|
|
6460198e1d | ||
|
|
662dc3fa85 | ||
|
|
5a4bce2816 | ||
|
|
d5ea0d5a17 | ||
|
|
9e525727fd | ||
|
|
95b6995649 | ||
|
|
745064c5ce | ||
|
|
223a3b46bf | ||
|
|
fee3e320ab | ||
|
|
e068cff37b | ||
|
|
28f08ed196 | ||
|
|
056d971000 | ||
|
|
aa5195e5a9 | ||
|
|
85aaaf80ac | ||
|
|
5d467a22d0 | ||
|
|
bcc1cfb67b | ||
|
|
70a24fba69 | ||
|
|
c3121bef84 | ||
|
|
8c25be7d05 | ||
|
|
7463e9d42c | ||
|
|
e4dd9f5bb3 | ||
|
|
4e59d648ef | ||
|
|
7538e76537 | ||
|
|
20ddc54edc | ||
|
|
ac75205933 | ||
|
|
17b58b159a | ||
|
|
191cf445ef | ||
|
|
65a0c08cb3 | ||
|
|
4da68ff89c | ||
|
|
a7da4525fd | ||
|
|
92dfbce45a | ||
|
|
bfdd6b5f7c | ||
|
|
a8a8c09b90 | ||
|
|
4bd13b81cf | ||
|
|
5a07600664 | ||
|
|
f2bc4b21cb | ||
|
|
778975fbde | ||
|
|
57f920624f | ||
|
|
3d87940da9 | ||
|
|
c33d0836eb | ||
|
|
a01cf21f2c | ||
|
|
10fc1d4a2a | ||
|
|
4fa973007d | ||
|
|
c27f5ec02e | ||
|
|
5d6cf3d17d | ||
|
|
f6c7731377 | ||
|
|
bcd739da76 | ||
|
|
bea4915317 | ||
|
|
f695ba4605 | ||
|
|
24983ba3af | ||
|
|
0a2a3b3377 | ||
|
|
d8d7193cbc | ||
|
|
042c9eed9c | ||
|
|
b0c94dd998 | ||
|
|
4c8cc9e823 | ||
|
|
132c9bbdff | ||
|
|
cc0caff8d0 | ||
|
|
3496df24f8 | ||
|
|
e4e35e4c57 | ||
|
|
838320785e | ||
|
|
91bea85519 | ||
|
|
88ea081661 | ||
|
|
39c0a0dba5 | ||
|
|
9d64d3e30d | ||
|
|
39ff238716 | ||
|
|
7c96152f3f | ||
|
|
a2440c19e1 | ||
|
|
24dff5ce85 | ||
|
|
1ca8e15d19 | ||
|
|
f77a8f09bb | ||
|
|
48a7c8140f | ||
|
|
b03ff46767 | ||
|
|
e66e6364c3 | ||
|
|
89ef7ac4f6 | ||
|
|
27e74ee064 | ||
|
|
83c44aa12c | ||
|
|
17da62d53b | ||
|
|
d43fc268f4 | ||
|
|
5a7ab9795d | ||
|
|
cfcdba5aea | ||
|
|
aaca6a6704 | ||
|
|
5f7c822deb | ||
|
|
53be091a31 | ||
|
|
04c68ba013 | ||
|
|
518ea9c8b5 | ||
|
|
6bb2142f35 | ||
|
|
6aa931c866 | ||
|
|
67ab54f820 | ||
|
|
cc9fc80328 | ||
|
|
3e8a8a6b85 | ||
|
|
70a2d0e5f3 | ||
|
|
09e8d7e1da | ||
|
|
3c3d6c1e7f | ||
|
|
0dcc95cd34 | ||
|
|
edae1b0480 | ||
|
|
5fb2cb7e76 | ||
|
|
403bb71c5d | ||
|
|
9e23b16ae7 | ||
|
|
60a93a2433 | ||
|
|
14dfafcb7c | ||
|
|
dcc06cf8c6 | ||
|
|
54c084040c | ||
|
|
a9c38f5a03 | ||
|
|
bce80cca0b | ||
|
|
f56fac6877 | ||
|
|
ef2286dca4 | ||
|
|
a4a012368e | ||
|
|
1b623ef346 | ||
|
|
f661d24544 | ||
|
|
cfe10553b2 | ||
|
|
cf1042f40d | ||
|
|
e77000076b | ||
|
|
49241664dc | ||
|
|
ffa3e9ec9a | ||
|
|
fbbe0d9ca9 | ||
|
|
001eeecc09 | ||
|
|
454a9c7076 | ||
|
|
fc934ce8e1 | ||
|
|
b9bcc28e0f | ||
|
|
b5ddca65f5 | ||
|
|
536805791e | ||
|
|
5a6c73c4c1 | ||
|
|
d50f5a0488 | ||
|
|
671a31a95a | ||
|
|
5e38f41530 | ||
|
|
7569465a61 | ||
|
|
5913166a13 | ||
|
|
6036562af2 | ||
|
|
39e6c258d5 | ||
|
|
1ab0291483 | ||
|
|
fe024502d4 | ||
|
|
9a76c6428a | ||
|
|
2d4053c792 | ||
|
|
a69254612c | ||
|
|
09a14c1270 | ||
|
|
b343068805 | ||
|
|
ecf50dde2b | ||
|
|
008404ffb3 | ||
|
|
2b6cca6bab | ||
|
|
fe968e19c0 | ||
|
|
479498d8be | ||
|
|
af9669acd0 | ||
|
|
d2eb98f859 | ||
|
|
ae11084f48 | ||
|
|
b02b554105 | ||
|
|
2d66ba918a | ||
|
|
400c0f35f0 | ||
|
|
5b8b265371 | ||
|
|
f3fb27005a | ||
|
|
79bb9bdde8 | ||
|
|
4b983e401d | ||
|
|
5f2f0a9a57 | ||
|
|
8b2ae6d464 | ||
|
|
91aae6a9f5 | ||
|
|
b0fc9ef05e | ||
|
|
5d6fbb6f04 | ||
|
|
301ebdbcd8 | ||
|
|
49bcf46b4f | ||
|
|
f7263c8ab8 | ||
|
|
c69772131a | ||
|
|
e8c025b898 | ||
|
|
8fb5c16bce | ||
|
|
f4551ddf3a | ||
|
|
f337488951 | ||
|
|
f104f11613 | ||
|
|
32e8f952fd | ||
|
|
c7cfc81c6f | ||
|
|
faeafc329c | ||
|
|
eeef0d13ff | ||
|
|
0686fca3b0 | ||
|
|
79b425326b | ||
|
|
2d879b9604 | ||
|
|
38eb14b013 | ||
|
|
ff2f43766e | ||
|
|
593d66ea54 | ||
|
|
ee8cbbf7ef | ||
|
|
474cb49d2c | ||
|
|
590f815dc0 | ||
|
|
a93d5246d2 | ||
|
|
3e2ab8e7ee | ||
|
|
a83270699f | ||
|
|
745afb32a3 | ||
|
|
2fcb8d915d | ||
|
|
a596b62a5f | ||
|
|
55b1504aab | ||
|
|
c42822669b | ||
|
|
b58d28c723 | ||
|
|
a6a34d8246 | ||
|
|
0080399db2 | ||
|
|
50b480ee23 | ||
|
|
da467b7837 | ||
|
|
70914b6c3e | ||
|
|
6c3fbfe0ca | ||
|
|
8e4b705e60 | ||
|
|
48d80d3194 | ||
|
|
38768c777d | ||
|
|
1daa841f31 | ||
|
|
a9f81d0ad3 | ||
|
|
76720424ab | ||
|
|
a7b639a66b | ||
|
|
1b78d221b6 | ||
|
|
ac7b1a3c66 | ||
|
|
039c3182aa | ||
|
|
55d94b1844 | ||
|
|
3baa69b43b | ||
|
|
5f92df97e6 | ||
|
|
47f297c495 | ||
|
|
58f09ba150 | ||
|
|
1142948945 | ||
|
|
9528160b03 | ||
|
|
4afdc54914 | ||
|
|
09973a6a56 | ||
|
|
f56bf3afef | ||
|
|
c4c7bbf46b | ||
|
|
7fdd374911 | ||
|
|
fb9ad9870d | ||
|
|
bbabc6f5e9 | ||
|
|
2060579bb4 | ||
|
|
b0f0940605 | ||
|
|
ee23df176d | ||
|
|
d53865d81b | ||
|
|
7becfd8adb | ||
|
|
a9feed56fe | ||
|
|
dea517c17c | ||
|
|
6d93ceb910 | ||
|
|
9b0ac5b83d | ||
|
|
acabb40171 | ||
|
|
04cd56ca16 | ||
|
|
4371512ba2 | ||
|
|
ced686e1cd | ||
|
|
272aab2397 | ||
|
|
a7f87f2ac8 | ||
|
|
f9e85155a8 | ||
|
|
d281c18951 | ||
|
|
04e7f4f8a7 | ||
|
|
a090c11f4a | ||
|
|
c303c697a9 | ||
|
|
9466c5ea10 | ||
|
|
1268fcf1cc | ||
|
|
86d06fa879 | ||
|
|
cfe79a73d6 | ||
|
|
d7a9d9ddc8 | ||
|
|
b3d45384e1 | ||
|
|
64099e37e5 | ||
|
|
2fe612d392 | ||
|
|
14d72b92cb | ||
|
|
8c0055723c | ||
|
|
bb9e368b2d | ||
|
|
2c203acbd2 | ||
|
|
7115eb573c | ||
|
|
e626c6f286 | ||
|
|
24058f9534 | ||
|
|
a8d47e9a72 | ||
|
|
9a2f51b48d | ||
|
|
667deef5f2 | ||
|
|
aa349aa935 | ||
|
|
666ab01d03 | ||
|
|
ffbab0136f | ||
|
|
b5f7399b2c | ||
|
|
f1892eeba2 | ||
|
|
e7128a57db | ||
|
|
793cafd294 | ||
|
|
9b1097effe | ||
|
|
079ae0e1f3 | ||
|
|
e181a9837a | ||
|
|
ca5c7022ef | ||
|
|
75c50392cb | ||
|
|
74f8e744ac | ||
|
|
1c657036da | ||
|
|
2869bb3ef3 | ||
|
|
784e117a8a | ||
|
|
8c2ea052de | ||
|
|
0b6bbf6733 | ||
|
|
2312df3718 | ||
|
|
095dd73c52 | ||
|
|
ac6ac42860 | ||
|
|
c6ce888cc7 |
20
.gitignore
vendored
20
.gitignore
vendored
@ -1,9 +1,23 @@
|
||||
target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
.vscode
|
||||
*.ui~
|
||||
resources.gresource
|
||||
_build
|
||||
_build/
|
||||
build/
|
||||
vendor/
|
||||
.criterion
|
||||
.criterion/
|
||||
org.gnome.*.json~
|
||||
podcasts-gtk/po/gnome-podcasts.pot
|
||||
|
||||
# scripts/test.sh
|
||||
target_*/
|
||||
|
||||
# flatpak-builder stuff
|
||||
.flatpak-builder/
|
||||
app/
|
||||
repo/
|
||||
|
||||
# Files configured by meson
|
||||
podcasts-gtk/src/config.rs
|
||||
podcasts-gtk/src/static_resource.rs
|
||||
|
||||
163
.gitlab-ci.yml
163
.gitlab-ci.yml
@ -1,143 +1,30 @@
|
||||
stages:
|
||||
- test
|
||||
- lint
|
||||
- review
|
||||
|
||||
variables:
|
||||
BUNDLE: "hammond-dev.flatpak"
|
||||
|
||||
.cargo_cache_template: &cargo_cache
|
||||
cache:
|
||||
# JOB_NAME - Each job will have it's own cache
|
||||
# COMMIT_REF_SLUG = Lowercase name of the branch
|
||||
# ^ Keep diffrerent caches for each branch
|
||||
key: "$CI_JOB_NAME"
|
||||
paths:
|
||||
- target/
|
||||
- .cargo_cache/
|
||||
|
||||
.cargo_test_template: &cargo_test
|
||||
stage: test
|
||||
|
||||
variables:
|
||||
RUSTFLAGS: "--cfg rayon_unstable"
|
||||
RUST_BACKTRACE: "FULL"
|
||||
|
||||
before_script:
|
||||
- apt-get update -yqq
|
||||
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson
|
||||
|
||||
- mkdir -p .cargo_cache
|
||||
# Only stuff inside the repo directory can be cached
|
||||
# Override the CARGO_HOME variable to force it location
|
||||
- export CARGO_HOME="${PWD}/.cargo_cache"
|
||||
script:
|
||||
- rustc -Vv && cargo -Vv
|
||||
# Force regeneration of gresources regardless of artifacts chage
|
||||
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
|
||||
|
||||
- cargo build
|
||||
- cargo test -- --test-threads=1
|
||||
- cargo test -- --test-threads=1 --ignored
|
||||
<<: *cargo_cache
|
||||
|
||||
rust:stable:
|
||||
# https://hub.docker.com/_/rust/
|
||||
image: "rust"
|
||||
<<: *cargo_test
|
||||
|
||||
rust:nightly:
|
||||
# https://hub.docker.com/r/rustlang/rust/
|
||||
image: "rustlang/rust:nightly"
|
||||
<<: *cargo_test
|
||||
only:
|
||||
- schedule
|
||||
- web
|
||||
include:
|
||||
- project: 'gnome/citemplates'
|
||||
file: 'flatpak/flatpak-ci-initiative-sdk-extensions.yml'
|
||||
# ref: ''
|
||||
|
||||
flatpak:
|
||||
image: registry.gitlab.com/alatiera/gnome-nightly-oci/rust-bundle:latest
|
||||
stage: test
|
||||
script:
|
||||
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
|
||||
# https://gitlab.gnome.org/World/hammond/issues/55
|
||||
# Force regeneration of gresources regardless of artifacts chage
|
||||
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
|
||||
image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.36'
|
||||
variables:
|
||||
MANIFEST_PATH: "org.gnome.Podcasts.Devel.json"
|
||||
FLATPAK_MODULE: "gnome-podcasts"
|
||||
MESON_ARGS: "-Dprofile=development"
|
||||
APP_ID: "org.gnome.Podcasts.Devel"
|
||||
RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo"
|
||||
BUNDLE: "org.gnome.Podcasts.Devel.flatpak"
|
||||
extends: '.flatpak'
|
||||
|
||||
# Build the flatpak repo
|
||||
- flatpak-builder --run app org.gnome.Hammond.json meson --prefix=/app --libdir=/app/lib _build
|
||||
- flatpak-builder --run app org.gnome.Hammond.json ninja -C _build install
|
||||
- flatpak-builder --finish-only app org.gnome.Hammond.json
|
||||
- flatpak build-export repo app
|
||||
|
||||
# Create a flatpak bundle
|
||||
- flatpak build-bundle repo ${BUNDLE} org.gnome.Hammond
|
||||
# Run the tests
|
||||
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1
|
||||
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored
|
||||
|
||||
artifacts:
|
||||
paths:
|
||||
- $BUNDLE
|
||||
expire_in: 30 days
|
||||
|
||||
cache:
|
||||
# JOB_NAME - Each job will have it's own cache
|
||||
# COMMIT_REF_SLUG = Lowercase name of the branch
|
||||
# ^ Keep diffrerent caches for each branch
|
||||
key: "$CI_JOB_NAME"
|
||||
paths:
|
||||
- .flatpak-builder/cache/
|
||||
- target/
|
||||
|
||||
review:
|
||||
stage: review
|
||||
dependencies:
|
||||
- flatpak
|
||||
script:
|
||||
- echo "Generating flatpak deployment"
|
||||
artifacts:
|
||||
paths:
|
||||
- $BUNDLE
|
||||
expire_in: 30 days
|
||||
environment:
|
||||
name: review/$CI_COMMIT_REF_NAME
|
||||
url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE}
|
||||
on_stop: stop_review
|
||||
except:
|
||||
- master@World/hammond
|
||||
|
||||
stop_review:
|
||||
stage: review
|
||||
script:
|
||||
- echo "Stopping flatpak deployment"
|
||||
when: manual
|
||||
environment:
|
||||
name: review/$CI_COMMIT_REF_NAME
|
||||
action: stop
|
||||
except:
|
||||
- master@World/hammond
|
||||
|
||||
# Configure and run rustfmt on nightly
|
||||
# Configure and run rustfmt
|
||||
# Exits and builds fails if on bad format
|
||||
rustfmt:
|
||||
image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:nightly"
|
||||
stage: lint
|
||||
script:
|
||||
- rustc -Vv && cargo -Vv
|
||||
- cargo fmt --version
|
||||
- cargo fmt --all -- --write-mode=diff
|
||||
|
||||
# Configure and run clippy on nightly
|
||||
# Only fails on errors atm.
|
||||
clippy:
|
||||
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly"
|
||||
stage: lint
|
||||
variables:
|
||||
RUSTFLAGS: "--cfg rayon_unstable"
|
||||
script:
|
||||
- rustc --version && cargo --version
|
||||
- cargo clippy --version
|
||||
# Force regeneration of gresources regardless of artifacts chage
|
||||
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
|
||||
- cargo clippy --all
|
||||
<<: *cargo_cache
|
||||
image: "rust:slim"
|
||||
stage: ".pre"
|
||||
script:
|
||||
- rustup component add rustfmt
|
||||
# Create blank versions of our configured files
|
||||
# so rustfmt does not yell about non-existent files or completely empty files
|
||||
- echo -e "" >> podcasts-gtk/src/config.rs
|
||||
- echo -e "" >> podcasts-gtk/src/static_resource.rs
|
||||
- rustc -Vv && cargo -Vv
|
||||
- cargo fmt --version
|
||||
- cargo fmt --all -- --color=always --check
|
||||
|
||||
@ -18,7 +18,7 @@ Some common cases might be:
|
||||
|
||||
Steps to reproduce:
|
||||
|
||||
1. Open Hammond
|
||||
1. Open GNOME Podcasts
|
||||
2. Do an action
|
||||
3. ...
|
||||
|
||||
|
||||
@ -1,9 +1,40 @@
|
||||
Detailed description of the issue. Put as much information as you can, potentially
|
||||
with images showing the issue.
|
||||
# Steps to reproduce
|
||||
<!--
|
||||
Explain in detail the steps on how the issue can be reproduced.
|
||||
-->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
Steps to reproduce:
|
||||
Reproducible in:
|
||||
<!--
|
||||
Please test if the issue was already fixed in the unstable version of the app.
|
||||
For that, follow these steps:
|
||||
1. Make sure Flatpak is installed or install it following these steps https://flatpak.org/setup
|
||||
2. Install the unstable version of the app following, flatpak bundles can be found in the CI artifacts.
|
||||
|
||||
1. Open Hammond
|
||||
2. Do an action
|
||||
3. ...
|
||||
If these steps failed, write in 'Other' the distribution you’re using and
|
||||
the version of the app.
|
||||
-->
|
||||
- Flatpak unstable: (yes or no) <!-- Write "yes" or "no" after the semicolon. -->
|
||||
- Other:
|
||||
|
||||
# Current behavior
|
||||
<!-- Describe the current behavior. -->
|
||||
|
||||
|
||||
# Expected behavior
|
||||
<!-- Describe the expected behavior. -->
|
||||
|
||||
|
||||
# Additional information
|
||||
<!--
|
||||
Provide more information that could be relevant.
|
||||
|
||||
If the issue is a crash, provide a stack trace following the steps in:
|
||||
https://wiki.gnome.org/Community/GettingInTouch/Bugzilla/GettingTraces
|
||||
-->
|
||||
|
||||
|
||||
<!-- Ignore the text under this line. -->
|
||||
/label ~"Bug"
|
||||
|
||||
@ -1,42 +1,41 @@
|
||||
Current problems
|
||||
<!--
|
||||
What are the problems that the current project has?
|
||||
# Current problems
|
||||
<!--
|
||||
What are the problems that the current project has?
|
||||
|
||||
For example:
|
||||
* User cannot use the keyboard to perform most common actions
|
||||
or
|
||||
* User cannot see documents from cloud services
|
||||
-->
|
||||
For example:
|
||||
* User cannot use the keyboard to perform most common actions
|
||||
or
|
||||
* User cannot see documents from cloud services
|
||||
-->
|
||||
|
||||
# Goals & use cases
|
||||
<!--
|
||||
What are the use cases that this proposal will cover? What are the end goals?
|
||||
# Goals & use cases
|
||||
<!--
|
||||
What are the use cases that this proposal will cover? What are the end goals?
|
||||
|
||||
For example:
|
||||
* User needs to share a file with their friends.
|
||||
or
|
||||
* It should be easy to edit a picture within the app.
|
||||
-->
|
||||
For example:
|
||||
* User needs to share a file with their friends.
|
||||
or
|
||||
* It should be easy to edit a picture within the app.
|
||||
-->
|
||||
|
||||
# Requirements
|
||||
<!--
|
||||
What does the solution needs to ensure for being succesful?
|
||||
# Requirements
|
||||
<!--
|
||||
What does the solution needs to ensure for being succesful?
|
||||
|
||||
For example:
|
||||
* Work on small form factors and touch
|
||||
or
|
||||
* Use the Meson build system and integrate with it
|
||||
-->
|
||||
For example:
|
||||
* Work on small form factors and touch
|
||||
or
|
||||
* Use the Meson build system and integrate with it
|
||||
-->
|
||||
|
||||
# Relevant art
|
||||
<!--
|
||||
Is there any product that has implemented something similar? Put links to other
|
||||
projects, pictures, links to other code, etc.
|
||||
-->
|
||||
# Relevant art
|
||||
<!--
|
||||
Is there any product that has implemented something similar? Put links to other
|
||||
projects, pictures, links to other code, etc.
|
||||
-->
|
||||
|
||||
# Proposal & plan
|
||||
<!-- What's the solution and how should be achieved? It can be split in smaller
|
||||
tasks of minimum change, so they can be delivered across several releases. -->
|
||||
|
||||
/label ~"Epic"
|
||||
# Proposal & plan
|
||||
<!-- What's the solution and how should be achieved? It can be split in smaller
|
||||
tasks of minimum change, so they can be delivered across several releases. -->
|
||||
|
||||
/label ~"Epic"
|
||||
@ -1,17 +1,24 @@
|
||||
Detailed description of the feature. Put as much information as you can.
|
||||
### Use cases
|
||||
<!--
|
||||
Describe what problem(s) the user is experiencing and that this request
|
||||
is trying to solve.
|
||||
-->
|
||||
|
||||
Proposed Mockups:
|
||||
|
||||
(Add mockups of the proposed feature)
|
||||
### Desired behavior
|
||||
<!-- Describe the desired functionality. -->
|
||||
|
||||
## Design Tasks
|
||||
|
||||
* [ ] design tasks
|
||||
### Benefits of the solution
|
||||
<!-- List the possible benefits of the solution and how it fits in the project. -->
|
||||
|
||||
## Development Tasks
|
||||
|
||||
* [ ] development tasks
|
||||
### Possible drawbacks
|
||||
<!--
|
||||
Describe possible drawbacks of the feature and list how it could affect
|
||||
the project i.e. UI discoverability, complexity, impact in more or less
|
||||
number of users, etc.
|
||||
-->
|
||||
|
||||
## QA Tasks
|
||||
|
||||
* [ ] qa (quality assurance) tasks
|
||||
<!-- Ignore the text under this line. -->
|
||||
/label ~"Feature"
|
||||
|
||||
1
.gitlab/merge_requests_templates/mr.md
Normal file
1
.gitlab/merge_requests_templates/mr.md
Normal file
@ -0,0 +1 @@
|
||||
### Please attach a relevant issue to this MR, if this doesn't exist please create one.
|
||||
210
CHANGELOG.md
210
CHANGELOG.md
@ -5,11 +5,195 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added:
|
||||
|
||||
### Changed:
|
||||
|
||||
### Fixed:
|
||||
|
||||
### Removed:
|
||||
|
||||
## [0.4.7] - 2019-10-23
|
||||
|
||||
### Added:
|
||||
- Improved appdata validation and meson tests World/podcasts!89
|
||||
- The ability to export show subscriptions to opml files World/podcasts!77
|
||||
- Support for feeds requiring authentication World/podcasts!120
|
||||
|
||||
### Changed:
|
||||
|
||||
- Episodes now have a checkmark to show whether or not they've been played World/podcasts!106
|
||||
- Changed to how errors are shown when adding podcasts World/podcasts!108 World/podcasts!109 World/podcasts!110
|
||||
- Improved integration of cargo and meson World/podcasts!94
|
||||
- Refactored some macros for error handling World/podcasts!82
|
||||
- Refactored the handling of styling changes World/podcasts!119
|
||||
- Updated the icon to better match the HIG guidlines World/podcasts#102
|
||||
- Made Podcasts use a GtkApplication subclass World/podcasts!113
|
||||
- Updated the MPRIS permissions in order to remove a sandbox hole World/podcasts!124
|
||||
- Bumped gtk and libhandy minimum versions
|
||||
|
||||
### Fixed:
|
||||
|
||||
- Rewind now works regardless if its the start or the end of the episode World/podcasts!83
|
||||
- Typos in the README and CONTRIBUTING docs World/podcast!97 World/podcast!98 World/podcast!99 World/podcasts!121
|
||||
- Show cover is reset properly now if there isn't an image World/podcasts#114
|
||||
- Query pairs are no longer stripped from URLs World/podcasts!111
|
||||
- Pause MPRIS button now works on KDE Plasma World/podcasts#115
|
||||
- The playback widget now properly reflects the playback state on episode change World/podcasts!116
|
||||
|
||||
### Removed:
|
||||
|
||||
- All preferences World/podcast!104
|
||||
|
||||
## [0.4.6] - 2018-10-07
|
||||
|
||||
### Added:
|
||||
- Felix, @haecker-felix, wrote an [mpris crate](https://crates.io/crates/mpris-player) and implemented MPRIS2 client side support! !74 #68
|
||||
|
||||
### Changed:
|
||||
- Download Cancel button was changed to an Icon instead of a label !72
|
||||
- The applciation will no longer scale below 360p in width 1933c79f7a87d8261d91ca4e14eb51c1ddc66624
|
||||
- Update to the latest HIG 5050dda4d2f75b706842de8507d115dd5a1bd0a9
|
||||
- Chris, @brainblasted, upgraded hyper to 0.12, this brings openssl 1.1 support !75
|
||||
- Pipeline backend is now completly migrated to tokio-runtime 0887789f5e653dd92ad397fb39561df6dffcb45c
|
||||
- Resume playing an episode will attempt to rewind the track only if more than a minute has passed since the last pause !76
|
||||
|
||||
### Fixed:
|
||||
- Fixed a regression where indexing feeds was blocking the `tokio reactor` #88 !70
|
||||
- Episodeds Listbox no longer resizes when a download starts #89 !72
|
||||
- The `total_size` label of the `EpisodeWidget` now behaves correctly if the request fails #90 !73
|
||||
- The Pipeline will no longer log things in stderr for Requests that returned 304 and are expected to be skipped da361d0cb93cd8edd076859b2c607509a96dac8d
|
||||
- A bug where the HomeView wold get into an invalid state if your only shows had no episodes 32bd2a89a34e8e940b3b260c6be76defe11835ed
|
||||
|
||||
### Translations:
|
||||
|
||||
**Added**
|
||||
- Brazilian Portuguese translation 586cf16f
|
||||
- Swedish translation 2e527250
|
||||
- Italian translation a23297e5
|
||||
- Friulian translation 60e09c0d
|
||||
- Hungarian translation 2751a828
|
||||
- Croatian translation 0476b67b
|
||||
- Latvian translation a681b2c9
|
||||
- Czech translation 3563a964
|
||||
- Catalan translation 6ea3fc91
|
||||
|
||||
**Updated**
|
||||
- German translation
|
||||
- Finnish translation
|
||||
- Polish translation
|
||||
- Turkish translation
|
||||
- Croatian translation
|
||||
- Indonesian translation
|
||||
- Spanish translation
|
||||
|
||||
|
||||
## [0.4.5] - 2018-08-31
|
||||
|
||||
### Added:
|
||||
- [OARS](https://hughsie.github.io/oars/) Tags where added for compatibility with Store clients b0c94dd9
|
||||
- Daniel added support for Translations !46
|
||||
- Svitozar Cherepii(@svito) created a [wiki page](https://wiki.gnome.org/Apps/Podcasts) 70e79e50
|
||||
- Libhandy was added as a dependancy #70
|
||||
- Development builds can now be installed in parallel with stable builds !64
|
||||
|
||||
### Changed:
|
||||
- The update indication was moved to an In-App notification #72
|
||||
- The app icon's accent color was changed from orange to red 0dfb4859
|
||||
- The stack switcher in the Headerbar is now insesitive on Empty Views !63
|
||||
|
||||
### Fixed:
|
||||
- Improved handling of HTTP redirections #64 !61 !62
|
||||
- Fixed a major performance regression when loading show covers !67
|
||||
- More refference cycles have been fixed !59
|
||||
- OPML import dialog now exits properly and no longer keeps the application from shuting down !65
|
||||
- Update action is disabled if there isn't something to update #71
|
||||
|
||||
### Translations:
|
||||
- Added Finish 93696026
|
||||
- Added Polish 1bd6efc0
|
||||
- Added Turkish 73929f2d
|
||||
- Added Spanish !46
|
||||
- Added German 6b6c390c
|
||||
- Added Galician 0060a634
|
||||
- Added Indonesian ded0224f
|
||||
- Added Korean 36f16963
|
||||
|
||||
|
||||
## [0.4.4] - 2018-07-31
|
||||
|
||||
### Changed:
|
||||
- `SendCell` crate was replaced with `Fragile`. (Jorda Petridis) 838320785ebbea94e009698b473495cfec076f54
|
||||
- Update dependancies (Jorda Petridis) 91bea8551998b16e44e5358fdd43c53422bcc6f3
|
||||
|
||||
### Fixed:
|
||||
- Fix more refference cycles. (Jorda Petridis) 3496df24f8d8bfa8c8a53d8f00262d42ee39b41c
|
||||
- Actually fix cargo-vendor (Jorda Petridis)
|
||||
|
||||
## [0.4.3] - 2018-07-27
|
||||
|
||||
### Fixed:
|
||||
|
||||
- Fix the cargo vendor config for the tarball releash script. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
|
||||
|
||||
## [0.4.2] - 2018-07-27
|
||||
|
||||
### Changed:
|
||||
|
||||
- Minimum size requested by the Views. (Jorda Petridis) 7c96152f3f53f271247230dccf1c9cd5947b685f
|
||||
|
||||
### Fixed:
|
||||
|
||||
- Screenshot metadata in appstream data. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
|
||||
|
||||
## [0.4.1] - 2018-07-26
|
||||
### Added:
|
||||
|
||||
- Custom icons for the fast-forward and rewind actions in the Player were added. (Tobias Bernard) e77000076b3d78b8625f4c7ef367376d0130ece6
|
||||
- Hicolor and symbolic icons for the Application. (Tobias Bernard and Sam Hewitt) edae1b04801dba9d91d5d4145db79b287f0eec2c
|
||||
- Basic prefferences dialog (Zander Brown). [34](https://gitlab.gnome.org/World/podcasts/merge_requests/34)
|
||||
- Dbus service preperation. Not used till the MPRIS2 integration has landed. (Zander Brown) [42](https://gitlab.gnome.org/World/podcasts/merge_requests/42)
|
||||
- Episodes and Images will only get drawn when needed. Big Performance impact. (Jordan Petridis) [43](https://gitlab.gnome.org/World/podcasts/merge_requests/43)
|
||||
|
||||
### Changed:
|
||||
|
||||
- The `ShowWidget` control button were moved to a secondary menu in the Headerbar. (Jordan Petridis) 536805791e336a3e112799be554706bb804d2bef
|
||||
- EmptyView layout improvements. (Jorda Petridis) 3c3d6c1e7f15b88308a9054b15a6ca0d8fa233ce 518ea9c8b57885c44bda9c418b19fef26ae0e55d
|
||||
- Improved the `AddButton` behavior. (Jorda Petridis) 67ab54f8203f19aad198dc49e935127d25432b41
|
||||
|
||||
### Fixed:
|
||||
|
||||
- A couple reffence cycles where fixed. (Jorda Petridis)
|
||||
|
||||
### Removed:
|
||||
|
||||
- The delay between the application startup and the `update_on_startup` action. (Jorda Petridis) 7569465a612ee5ef84d0e58f4e1010c8d14080d4
|
||||
|
||||
## [0.4.0] - 2018-07-04
|
||||
### Added:
|
||||
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown)
|
||||
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
|
||||
|
||||
### Changed:
|
||||
- The `FileChooser` of the OPML import was changed to use the `FileChooserNative` widget/API. (ZanderBrown)
|
||||
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
|
||||
- The `EpisdeWidget` was refactored.
|
||||
[!38](https://gitlab.gnome.org/World/podcasts/merge_requests/38)
|
||||
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
|
||||
- An embeded audio player was implemented!
|
||||
[!40](https://gitlab.gnome.org/World/podcasts/merge_requests/40)
|
||||
- Various Database changes.
|
||||
[!41](https://gitlab.gnome.org/World/podcasts/merge_requests/41)
|
||||
|
||||
### Fixed:
|
||||
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/podcasts/merge_requests/37)
|
||||
|
||||
## [0.3.4] - 2018-05-20
|
||||
### Fixed:
|
||||
- Flatpak can now access the Home folder. This fixes the OPML import feature from
|
||||
not being able to access any file.
|
||||
|
||||
## [0.3.3] - 2018-05-19
|
||||
### Added:
|
||||
- Initial functionality for importing shows from an OPML file was implemented.
|
||||
@ -29,9 +213,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
|
||||
### Changed:
|
||||
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
|
||||
[#7](https://gitlab.gnome.org/World/hammond/issues/7)
|
||||
[#7](https://gitlab.gnome.org/World/podcasts/issues/7)
|
||||
- Bold, italics links and some other `html` tags can now be rendered in the Show Description.
|
||||
[#25](https://gitlab.gnome.org/World/hammond/issues/25)
|
||||
[#25](https://gitlab.gnome.org/World/podcasts/issues/25)
|
||||
- `Rayon` Threadpools are now used instead of unlimited one-off threads.
|
||||
- `EpisdeWidget`s are now loaded asynchronously accross views.
|
||||
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
|
||||
@ -39,7 +223,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
- `ShowWidget` Description is inside a scrolled window now
|
||||
|
||||
### Fixed:
|
||||
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/hammond/issues/57)
|
||||
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/podcasts/issues/57)
|
||||
- Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
|
||||
|
||||
### Removed:
|
||||
@ -48,27 +232,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
## [0.3.1] - 2018-03-28
|
||||
### Added:
|
||||
- Ability to mark all episodes of a Show as watched.
|
||||
[#47](https://gitlab.gnome.org/World/hammond/issues/47)
|
||||
[#47](https://gitlab.gnome.org/World/podcasts/issues/47)
|
||||
- Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
|
||||
[#49](https://gitlab.gnome.org/World/hammond/issues/49)
|
||||
[#49](https://gitlab.gnome.org/World/podcasts/issues/49)
|
||||
- Hammond now remembers the window size and position. (Rowan Lewis)
|
||||
[#50](https://gitlab.gnome.org/World/hammond/issues/50)
|
||||
[#50](https://gitlab.gnome.org/World/podcasts/issues/50)
|
||||
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
|
||||
[!22](https://gitlab.gnome.org/World/hammond/merge_requests/22) [!23](https://gitlab.gnome.org/World/hammond/merge_requests/23)
|
||||
[!22](https://gitlab.gnome.org/World/podcasts/merge_requests/22) [!23](https://gitlab.gnome.org/World/podcasts/merge_requests/23)
|
||||
- Shows without episodes now display an empty message similar to EmptyView.
|
||||
[#44](https://gitlab.gnome.org/World/hammond/issues/44)
|
||||
[#44](https://gitlab.gnome.org/World/podcasts/issues/44)
|
||||
|
||||
### Changed:
|
||||
- EpisdeWidget has been reimplemented as a compile time state machine.
|
||||
[!18](https://gitlab.gnome.org/World/hammond/merge_requests/18)
|
||||
[!18](https://gitlab.gnome.org/World/podcasts/merge_requests/18)
|
||||
- Content Views no longer scroll horizontally when shrunk bellow their minimum size.
|
||||
[#35](https://gitlab.gnome.org/World/hammond/issues/35)
|
||||
[#35](https://gitlab.gnome.org/World/podcasts/issues/35)
|
||||
- Some requests now use the Tor Browser's user agent. (Rowan Lewis)
|
||||
[#53](https://gitlab.gnome.org/World/hammond/issues/53)
|
||||
[#53](https://gitlab.gnome.org/World/podcasts/issues/53)
|
||||
|
||||
### Fixed:
|
||||
- Double border aroun the main window was fixed. (Rowan Lewis)
|
||||
[#52](https://gitlab.gnome.org/World/hammond/issues/52)
|
||||
[#52](https://gitlab.gnome.org/World/podcasts/issues/52)
|
||||
|
||||
## [0.3.0] - 2018-02-11
|
||||
- Tobias Bernard Redesigned the whole Gtk+ client.
|
||||
@ -88,4 +272,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
- Added appdata.xml file
|
||||
|
||||
## [0.1.0] - 2017-11-13
|
||||
- Initial Release
|
||||
- Initial Release
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
## Contributing to Hammond
|
||||
## Contributing to GNOME Podcasts
|
||||
|
||||
Thank you for looking in this file!
|
||||
|
||||
When contributing to the development of Hammond, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
|
||||
When contributing to the development of GNOME Podcasts, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
|
||||
|
||||
If you have any questions regarding the use or development of Hammond,
|
||||
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
|
||||
If you have any questions regarding the use or development of GNOME Podcasts,
|
||||
want to discuss design or simply hang out, please join us in [#gnome-podcasts:matrix.org](https://matrix.to/#/#gnome-podcasts:matrix.org) or [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
|
||||
|
||||
Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOfConduc), please follow it in all your interactions with the project.
|
||||
Please note we have a [code of conduct](/code-of-conduct.md), please follow it in all your interactions with the project.
|
||||
|
||||
## Source repository
|
||||
|
||||
Hammond's main source repository is at gitlab.gnome.org. You can view
|
||||
the web interface [here](https://gitlab.gnome.org/World/hammond)
|
||||
GNOME Podcasts's main source repository is at gitlab.gnome.org. You can view
|
||||
the web interface [here](https://gitlab.gnome.org/World/podcasts)
|
||||
|
||||
Development happens in the master branch.
|
||||
|
||||
@ -26,9 +26,12 @@ makes things easier for the maintainers.
|
||||
|
||||
We use [rustfmt](https://github.com/rust-lang-nursery/rustfmt) for code formatting and we enforce it on the gitlab-CI server.
|
||||
|
||||
Quick setup
|
||||
***Installing rustfmt*** As of 2019/Jan, our continuous integration
|
||||
pipeline assumes the version of rustfmt that is distributed through the
|
||||
stable channel of [rustup](rustup.rs). You can install it with
|
||||
|
||||
```
|
||||
cargo install rustfmt-nightly
|
||||
rustup component add rustfmt
|
||||
cargo fmt --all
|
||||
```
|
||||
|
||||
@ -36,7 +39,7 @@ It is recommended to add a pre-commit hook to run cargo test and `cargo fmt`.
|
||||
Don't forget to `git add` again after `cargo fmt`.
|
||||
```
|
||||
#!/bin/sh
|
||||
cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
|
||||
cargo test -- --test-threads=1 && cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
## Running the test suite
|
||||
@ -44,14 +47,14 @@ cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
|
||||
Running the tests requires an internet connection and it it will download some files from the [Internet Archive](archive.org)
|
||||
|
||||
The test suite sets a temporary sqlite database in the `/tmp` folder.
|
||||
Due to that it's not possible to run them in parrallel.
|
||||
Due to that it's not possible to run them in parallel.
|
||||
|
||||
In order to run the test suite use the following: `cargo test -- --test-threads=1`
|
||||
|
||||
# Issues, issues and more issues!
|
||||
|
||||
There are many ways you can contribute to Hammond, and all of them involve creating issues
|
||||
in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/issues). This is the entry point for your contribution.
|
||||
There are many ways you can contribute to GNOME Podcasts, and all of them involve creating issues
|
||||
in [GNOME Podcasts issue tracker](https://gitlab.gnome.org/World/podcasts/issues). This is the entry point for your contribution.
|
||||
|
||||
To create an effective and high quality ticket, try to put the following information on your
|
||||
ticket:
|
||||
@ -74,7 +77,7 @@ If it's an issue, add the steps to reproduce like this:
|
||||
|
||||
Steps to reproduce:
|
||||
|
||||
1. Open Hammond
|
||||
1. Open GNOME Podcasts
|
||||
2. Do an Action
|
||||
3. ...
|
||||
|
||||
@ -91,13 +94,13 @@ Steps to reproduce:
|
||||
* [ ] qa (quality assurance) tasks
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
## Merge Request Process
|
||||
|
||||
1. Ensure your code compiles. Run `make` before creating the pull request.
|
||||
1. Ensure your code compiles. Run `meson` & `ninja` before creating the merge request.
|
||||
2. Ensure the test suit passes. Run `cargo test -- --test-threads=1`.
|
||||
3. Ensure your code is properly formated. Run `cargo fmt --all`.
|
||||
3. Ensure your code is properly formatted. Run `cargo fmt --all`.
|
||||
4. If you're adding new API, it must be properly documented.
|
||||
5. The commit message is formatted as follows:
|
||||
5. The commit message has to be formatted as follows:
|
||||
```
|
||||
component: <summary>
|
||||
|
||||
@ -107,8 +110,8 @@ Steps to reproduce:
|
||||
|
||||
<link to the bug ticket>
|
||||
```
|
||||
6. You may merge the pull request in once you have the sign-off of the maintainers, or if you
|
||||
6. You may merge the merge request once you have the sign-off of the maintainers, or if you
|
||||
do not have permission to do that, you may request the second reviewer to merge it for you.
|
||||
|
||||
## Code of Conduct
|
||||
We follow the Gnome [Code of Conduct.](https://wiki.gnome.org/Foundation/CodeOfConduct)
|
||||
We follow the [GNOME Foundation Code of Conduct](/code-of-conduct.md).
|
||||
|
||||
3355
Cargo.lock
generated
3355
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"hammond-data",
|
||||
"hammond-downloader",
|
||||
"hammond-gtk"
|
||||
"podcasts-data",
|
||||
"podcasts-downloader",
|
||||
"podcasts-gtk"
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
debug = true
|
||||
|
||||
4
LICENSE
4
LICENSE
@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Hammond
|
||||
GNOME Podcasts
|
||||
Copyright (C) 2017 Jordan Petridis
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
Hammond Copyright (C) 2017 Jordan Petridis
|
||||
GNOME Podcasts Copyright (C) 2017 Jordan Petridis
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
124
README.md
124
README.md
@ -1,66 +1,63 @@
|
||||
# Hammond
|
||||
# GNOME Podcasts
|
||||
|
||||
### A Podcast Client for GNOME written in Rust.
|
||||
### A Podcast application for GNOME.
|
||||
Listen to your favorite podcasts, right from your desktop.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Available on Flathub
|
||||
|
||||
[](https://flathub.org/apps/details/org.gnome.Podcasts)
|
||||
|
||||
## Quick start
|
||||
|
||||
Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) >= 3.28.
|
||||
GNOME Podcasts can be built and run with [Gnome Builder][builder] >= 3.28.
|
||||
Just clone the repo and hit the run button!
|
||||
|
||||
Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
|
||||
You can get Builder from [here][get_builder].
|
||||
|
||||
## Broken Feeds
|
||||
|
||||
Found a feed that does not work in Hammond?
|
||||
Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
|
||||
Found a feed that does not work in GNOME Podcasts?
|
||||
Please [open an issue][new_issue] and choose the `BrokenFeed` template so we will know and fix it!
|
||||
|
||||
## Getting in Touch
|
||||
|
||||
If you have any questions regarding the use or development of Hammond,
|
||||
want to discuss design or simply hang out, please join us in `#hammond` on
|
||||
[irc.gnome.org.][irc] or [matrix][matrix].
|
||||
|
||||
Note:
|
||||
|
||||
There isn't much documentation yet, so you will probably have question about parts of the Code.
|
||||
If you have any questions regarding the use or development of GNOME Podcasts,
|
||||
want to discuss design or simply hang out, please join us on our [irc][irc] or [matrix][matrix] channel.
|
||||
|
||||
## Building
|
||||
|
||||
### Flatpak
|
||||
|
||||
Flatpak is the reccomended way of building and installing Hammond.
|
||||
Flatpak is the recommended way of building and installing GNOME Podcasts.
|
||||
Here are the dependencies you will need.
|
||||
|
||||
#### Building a Flatpak
|
||||
```sh
|
||||
# Add flathub and the gnome-nightly repo
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists gnome-nightly https://nightly.gnome.org/gnome-nightly.flatpakrepo
|
||||
|
||||
Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
|
||||
# Install the gnome-nightly Sdk and Platform runtime
|
||||
flatpak install --user gnome-nightly org.gnome.Sdk org.gnome.Platform
|
||||
|
||||
```bash
|
||||
# Add flathub repo
|
||||
flatpak --user remote-add flathub --if-not-exists https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
# Add the gnome-nightly repo
|
||||
flatpak --user remote-add gnome-nightly --if-not-exists https://sdk.gnome.org/gnome-nightly.flatpakrepo
|
||||
# Install the gnome-nightly Sdk and Platform runtim
|
||||
flatpak --user install gnome-nightly org.gnome.Sdk org.gnome.Platform
|
||||
# Install the required rust-stable extension from flathub
|
||||
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
|
||||
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
|
||||
flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//19.08
|
||||
```
|
||||
|
||||
To install the resulting flatpak you can do:
|
||||
|
||||
```bash
|
||||
flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
|
||||
flatpak install --user --bundle hammond.flatpak
|
||||
flatpak-builder --user --install --force-clean --repo=repo podcasts org.gnome.Podcasts.json
|
||||
```
|
||||
|
||||
### Building from soure
|
||||
### Building from source
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.gnome.org/World/hammond.git
|
||||
cd hammond/
|
||||
git clone https://gitlab.gnome.org/World/podcasts.git
|
||||
cd gnome-podcasts/
|
||||
meson --prefix=/usr build
|
||||
ninja -C build
|
||||
sudo ninja -C build install
|
||||
@ -68,41 +65,33 @@ sudo ninja -C build install
|
||||
|
||||
#### Dependencies
|
||||
|
||||
* Rust stable 1.26 or later along with cargo.
|
||||
* Gtk+ 3.22 or later
|
||||
* Rust stable 1.34 or later along with cargo.
|
||||
* Gtk+ 3.24.11 or later
|
||||
* Gstreamer 1.16 or later
|
||||
* libhandy 0.0.11 or later
|
||||
* Meson
|
||||
* A network connection
|
||||
|
||||
Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first
|
||||
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```sh
|
||||
apt-get update -yqq
|
||||
apt-get install -yqq --no-install-recommends build-essential
|
||||
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
|
||||
```
|
||||
|
||||
**Fedora**
|
||||
|
||||
```sh
|
||||
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
|
||||
```
|
||||
|
||||
If you happen to build it on other distributions please let me know the names
|
||||
of the corresponding libraries. Feel free to open a MR or an Issue to note it.
|
||||
|
||||
## Contributing
|
||||
|
||||
There alot of thins yet to be done.
|
||||
There are a lot of things yet to be done.
|
||||
|
||||
If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines].
|
||||
|
||||
You can start by taking a look at [Issues](https://gitlab.gnome.org/World/hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/World/hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
|
||||
You can start by taking a look at [Issues][issues] or by opening a [New issue][new_issue].
|
||||
|
||||
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
|
||||
|
||||
[contribution-guidelines]: https://gitlab.gnome.org/World/hammond/blob/master/CONTRIBUTING.md
|
||||
[contribution-guidelines]: https://gitlab.gnome.org/World/podcasts/blob/master/CONTRIBUTING.md
|
||||
|
||||
### Translations
|
||||
|
||||
Translation of this project takes place on the GNOME translation platform,
|
||||
[Damned Lies](https://l10n.gnome.org/module/podcasts). For further
|
||||
information on how to join a language team, or even to create one, please see
|
||||
[GNOME Translation Project wiki page](https://wiki.gnome.org/TranslationProject).
|
||||
|
||||
|
||||
## Overview
|
||||
@ -110,15 +99,15 @@ There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source c
|
||||
```sh
|
||||
$ tree -d
|
||||
├── screenshots # png's used in the README.md
|
||||
├── hammond-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
|
||||
├── podcasts-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
|
||||
│ ├── migrations # Diesel SQL migrations.
|
||||
│ │ └── ...
|
||||
│ ├── src
|
||||
│ └── tests
|
||||
│ └── feeds # Raw RSS Feeds used for tests.
|
||||
├── hammond-downloader # Really basic, Really crappy downloader.
|
||||
├── podcasts-downloader # Really basic, Really crappy downloader.
|
||||
│ └── src
|
||||
├── hammond-gtk # The Gtk+ Client
|
||||
├── podcasts-gtk # The Gtk+ Client
|
||||
│ ├── resources # GResources folder
|
||||
│ │ └── gtk # Contains the glade.ui files.
|
||||
│ └── src
|
||||
@ -128,18 +117,27 @@ $ tree -d
|
||||
|
||||
## A note about the project's name
|
||||
|
||||
The project was named after Allan Moore's character [Evey Hammond](https://en.wikipedia.org/wiki/Evey_Hammond) from the graphic novel V for Vendetta.
|
||||
|
||||
It has nothing to do with the horrible headlines on the news.
|
||||
The project used to be called Hammond, after Allan Moore's character [Evey Hammond][hammond] from the graphic novel V for Vendetta.
|
||||
It was renamed to GNOME Podcasts on 2018/07/24 shortly before its first public release.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Design/Apps/Music) and [Vocal](http://vocalproject.net/).
|
||||
GNOME Podcasts's design is heavily inspired by [GNOME Music][music] and [Vocal][vocal].
|
||||
|
||||
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
|
||||
We also copied some elements from [GNOME News][news].
|
||||
|
||||
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
|
||||
And almost the entirety of the build system is copied from the [Fractal][fractal] project.
|
||||
|
||||
[vendor]: https://github.com/alexcrichton/cargo-vendor
|
||||
[irc]: irc://irc.gnome.org/#hammond
|
||||
[matrix]: https://matrix.to/#/#hammond:matrix.org
|
||||
[matrix]: https://matrix.to/#/#gnome-podcasts:matrix.org
|
||||
[flatpak_setup]: https://flatpak.org/setup/
|
||||
[music]: https://wiki.gnome.org/Design/Apps/Music
|
||||
[vocal]: http://vocalproject.net/
|
||||
[news]: https://wiki.gnome.org/Design/Apps/Potential/News
|
||||
[fractal]: https://gitlab.gnome.org/World/fractal
|
||||
[hammond]: https://en.wikipedia.org/wiki/Evey_Hammond
|
||||
[issues]: https://gitlab.gnome.org/World/podcasts/issues
|
||||
[new_issue]: https://gitlab.gnome.org/World/podcasts/issues/new
|
||||
[builder]: https://wiki.gnome.org/Apps/Builder
|
||||
[get_builder]: https://wiki.gnome.org/Apps/Builder/Downloads
|
||||
|
||||
11
TODO.md
11
TODO.md
@ -4,12 +4,11 @@
|
||||
|
||||
## Priorities
|
||||
|
||||
- [ ] Unplayed Only and Downloaded only view.
|
||||
- [ ] OPML import/export // Probably need to create a crate.
|
||||
|
||||
## Second
|
||||
|
||||
- [ ] Make use of file metadas, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
|
||||
- [ ] Make use of file metadas?, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
|
||||
- [ ] Episode queue
|
||||
- [ ] Embedded player
|
||||
- [ ] MPRIS integration
|
||||
@ -19,14 +18,8 @@
|
||||
|
||||
- [ ] Download Queue
|
||||
- [ ] Ability to Stream content on demand
|
||||
- [ ] soundcloud and itunes feeds // [This](http://getrssfeed.com) seems intresting.
|
||||
- [ ] rss feeds from soundcloud urls? // [This](http://getrssfeed.com) seems intresting.
|
||||
- [ ] Integrate with Itunes API for various crap?
|
||||
- [ ] YoutubeFeeds?
|
||||
|
||||
## Rest Tasks
|
||||
|
||||
**Would be nice:**
|
||||
|
||||
- [ ] Make Podcast cover fetchng and loading not block the execution of the program at startup.
|
||||
- [ ] Lazy evaluate episode loading based on the show_widget's scrolling.
|
||||
|
||||
|
||||
126
code-of-conduct.md
Normal file
126
code-of-conduct.md
Normal file
@ -0,0 +1,126 @@
|
||||
# GNOME Code of Conduct
|
||||
|
||||
Thank you for being a part of the GNOME project. We value your participation and want everyone to have an enjoyable and fulfilling experience. Accordingly, all participants are expected to follow this Code of Conduct, and to show respect, understanding, and consideration to one another. Thank you for helping make this a welcoming, friendly community for everyone.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies to all online GNOME community spaces, including, but not limited to:
|
||||
|
||||
* Issue tracking systems - bugzilla.gnome.org
|
||||
* Documentation and tutorials - developer.gnome.org
|
||||
* Code repositories - git.gnome.org and gitlab.gnome.org
|
||||
* Mailing lists - mail.gnome.org
|
||||
* Wikis - wiki.gnome.org
|
||||
* Chat and forums - irc.gnome.org, discourse.gnome.org, GNOME Telegram channels, and GNOME groups and channels on Matrix.org (including bridges to GNOME IRC channels)
|
||||
* Community spaces hosted on gnome.org infrastructure
|
||||
* Any other channels or groups which exist in order to discuss GNOME project activities
|
||||
|
||||
Communication channels and private conversations that are normally out of scope may be considered in scope if a GNOME participant is being stalked or harassed. Social media conversations may be considered in-scope if the incident occurred under a GNOME event hashtag, or when an official GNOME account on social media is tagged, or within any other discussion about GNOME. The GNOME Foundation reserves the right to take actions against behaviors that happen in any context, if they are deemed to be relevant to the GNOME project and its participants.
|
||||
|
||||
All participants in GNOME online community spaces are subject to the Code of Conduct. This includes GNOME Foundation board members, corporate sponsors, and paid employees. This also includes volunteers, maintainers, leaders, contributors, contribution reviewers, issue reporters, GNOME users, and anyone participating in discussion in GNOME online spaces.
|
||||
|
||||
## Reporting an Incident
|
||||
|
||||
If you believe that someone is violating the Code of Conduct, or have
|
||||
any other concerns, please [contact the Code of Conduct committee](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide).
|
||||
|
||||
## Our Standards
|
||||
|
||||
The GNOME online community is dedicated to providing a positive experience for everyone, regardless of:
|
||||
|
||||
* age
|
||||
* body size
|
||||
* caste
|
||||
* citizenship
|
||||
* disability
|
||||
* education
|
||||
* ethnicity
|
||||
* familial status
|
||||
* gender expression
|
||||
* gender identity
|
||||
* genetic information
|
||||
* immigration status
|
||||
* level of experience
|
||||
* nationality
|
||||
* personal appearance
|
||||
* pregnancy
|
||||
* race
|
||||
* religion
|
||||
* sex characteristics
|
||||
* sexual orientation
|
||||
* sexual identity
|
||||
* socio-economic status
|
||||
* tribe
|
||||
* veteran status
|
||||
|
||||
### Community Guidelines
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* **Be friendly.** Use welcoming and inclusive language.
|
||||
* **Be empathetic.** Be respectful of differing viewpoints and experiences.
|
||||
* **Be respectful.** When we disagree, we do so in a polite and constructive manner.
|
||||
* **Be considerate.** Remember that decisions are often a difficult choice between competing priorities. Focus on what is best for the community. Keep discussions around technology choices constructive and respectful.
|
||||
* **Be patient and generous.** If someone asks for help it is because they need it. When documentation is available that answers the question, politely point them to it. If the question is off-topic, suggest a more appropriate online space to seek help.
|
||||
* **Try to be concise.** Read the discussion before commenting in order to not repeat a point that has been made.
|
||||
|
||||
### Inappropriate Behavior
|
||||
|
||||
Community members asked to stop any inappropriate behavior are expected to comply immediately.
|
||||
|
||||
We want all participants in the GNOME community have the best possible experience they can. In order to be clear what that means, we've provided a list of examples of behaviors that are inappropriate for GNOME community spaces:
|
||||
|
||||
* **Deliberate intimidation, stalking, or following.**
|
||||
* **Sustained disruption of online discussion, talks, or other events.** Sustained disruption of events, online discussions, or meetings, including talks and presentations, will not be tolerated. This includes 'Talking over' or 'heckling' event speakers or influencing crowd actions that cause hostility in event sessions. Sustained disruption also includes drinking alcohol to excess or using recreational drugs to excess, or pushing others to do so.
|
||||
* **Harassment of people who don't drink alcohol.** We do not tolerate derogatory comments about those who abstain from alcohol or other substances. We do not tolerate pushing people to drink, talking about their abstinence or preferences to others, or pressuring them to drink - physically or through jeering.
|
||||
* **Sexist, racist, homophobic, transphobic, ableist language or otherwise exclusionary language.** This includes deliberately referring to someone by a gender that they do not identify with, and/or questioning the legitimacy of an individual's gender identity. If you're unsure if a word is derogatory, don't use it. This also includes repeated subtle and/or indirect discrimination.
|
||||
* **Unwelcome sexual attention or behavior that contributes to a sexualized environment.** This includes sexualized comments, jokes or imagery in interactions, communications or presentation materials, as well as inappropriate touching, groping, or sexual advances. Sponsors should not use sexualized images, activities, or other material. Meetup organizing staff and other volunteer organizers should not use sexualized clothing/uniforms/costumes, or otherwise create a sexualized environment.
|
||||
* **Unwelcome physical contact.** This includes touching a person without permission, including sensitive areas such as their hair, pregnant stomach, mobility device (wheelchair, scooter, etc) or tattoos. This also includes physically blocking or intimidating another person. Physical contact or simulated physical contact (such as emojis like "kiss") without affirmative consent is not acceptable. This includes sharing or distribution of sexualized images or text.
|
||||
* **Violence or threats of violence.** Violence and threats of violence are not acceptable - online or offline. This includes incitement of violence toward any individual, including encouraging a person to commit self-harm. This also includes posting or threatening to post other people's personally identifying information ("doxxing") online.
|
||||
* **Influencing or encouraging inappropriate behavior.** If you influence or encourage another person to violate the Code of Conduct, you may face the same consequences as if you had violated the Code of Conduct.
|
||||
* **Possession of an offensive weapon at a GNOME event.** This includes anything deemed to be a weapon by the event organizers.
|
||||
|
||||
The GNOME community prioritizes marginalized people's safety over privileged people's comfort. The committee will not act on complaints regarding:
|
||||
|
||||
* "Reverse"-isms, including "reverse racism," "reverse sexism," and "cisphobia"
|
||||
* Reasonable communication of boundaries, such as "leave me alone," "go away," or "I'm not discussing this with you."
|
||||
* Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions
|
||||
* Communicating boundaries or criticizing oppressive behavior in a "tone" you don't find congenial
|
||||
|
||||
The examples listed above are not against the Code of Conduct. If you have questions about the above statements, please [read this document](https://github.com/sagesharp/code-of-conduct-template/blob/master/code-of-conduct/example-reversisms.md#supporting-diversity).
|
||||
|
||||
If a participant engages in behavior that violates this code of conduct, the GNOME Code of Conduct committee may take any action they deem appropriate. Examples of consequences are outlined in the [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures).
|
||||
|
||||
## Procedure for Handling Incidents
|
||||
|
||||
* [Reporter Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide)
|
||||
|
||||
* [Moderator Procedures](https://wiki.gnome.org/Foundation/CodeOfConduct/ModeratorProcedures)
|
||||
|
||||
* [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures)
|
||||
|
||||
## License
|
||||
|
||||
The GNOME Online Code of Conduct is licensed under a [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/)
|
||||
|
||||

|
||||
|
||||
## Attribution
|
||||
|
||||
The GNOME Online Code of Conduct was forked from the example policy from the [Geek Feminism wiki, created by the Ada Initiative and other volunteers](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), which is under a Creative Commons Zero license.
|
||||
|
||||
Additional language was incorporated and modified from the following Codes of Conduct:
|
||||
|
||||
* [Citizen Code of Conduct](http://citizencodeofconduct.org/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/).
|
||||
* [Code of Conduct template](https://github.com/sagesharp/code-of-conduct-template/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/) by [Otter Tech](https://otter.technology/code-of-conduct-training)
|
||||
* [Contributor Covenant version 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct) (licensed [CC BY 4.0](https://github.com/ContributorCovenant/contributor_covenant/blob/master/LICENSE.md))
|
||||
* [Data Carpentry Code of Conduct](https://docs.carpentries.org/topic_folders/policies/index_coc.html) is licensed [Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/)
|
||||
* [Django Project Code of Conduct](https://www.djangoproject.com/conduct/) is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/)
|
||||
* [Fedora Code of Conduct](http://fedoraproject.org/code-of-conduct)
|
||||
* [Geek Feminism Anti-harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy) which is under a [Creative Commons Zero license](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
* [Previous GNOME Foundation Code of Conduct](https://wiki.gnome.org/action/recall/Foundation/CodeOfConduct/Old)
|
||||
* [LGBTQ in Technology Slack Code of Conduct](https://lgbtq.technology/coc.html) licensed [Creative Commons Zero](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
* [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) is licensed [Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/).
|
||||
* [Python Mentors Code of Conduct](http://pythonmentors.com/)
|
||||
* [Speak Up! Community Code of Conduct](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html), licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/)
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
[package]
|
||||
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
|
||||
name = "hammond-data"
|
||||
version = "0.1.0"
|
||||
workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
ammonia = "1.1.0"
|
||||
chrono = "0.4.2"
|
||||
derive_builder = "0.5.1"
|
||||
lazy_static = "1.0.0"
|
||||
log = "0.4.1"
|
||||
rayon = "1.0.1"
|
||||
rayon-futures = "0.1.0"
|
||||
rfc822_sanitizer = "0.3.3"
|
||||
rss = "1.5.0"
|
||||
url = "1.7.0"
|
||||
xdg = "2.1.0"
|
||||
xml-rs = "0.8.0"
|
||||
futures = "0.1.21"
|
||||
hyper = "0.11.27"
|
||||
tokio-core = "0.1.17"
|
||||
hyper-tls = "0.1.3"
|
||||
native-tls = "0.1.5"
|
||||
num_cpus = "1.8.0"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["sqlite", "r2d2"]
|
||||
version = "1.2.2"
|
||||
|
||||
[dependencies.diesel_migrations]
|
||||
features = ["sqlite"]
|
||||
version = "1.2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.4.2"
|
||||
tempdir = "0.3.7"
|
||||
criterion = "0.2.3"
|
||||
pretty_assertions = "0.5.1"
|
||||
maplit = "1.0.1"
|
||||
|
||||
[[bench]]
|
||||
name = "bench"
|
||||
harness = false
|
||||
@ -1,104 +0,0 @@
|
||||
#![allow(unused)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate criterion;
|
||||
use criterion::Criterion;
|
||||
|
||||
// extern crate futures;
|
||||
extern crate hammond_data;
|
||||
extern crate hyper;
|
||||
extern crate hyper_tls;
|
||||
extern crate rand;
|
||||
extern crate tokio_core;
|
||||
// extern crate rayon;
|
||||
extern crate rss;
|
||||
|
||||
// use rayon::prelude::*;
|
||||
|
||||
// use futures::future::*;
|
||||
use tokio_core::reactor::Core;
|
||||
|
||||
use hammond_data::database::truncate_db;
|
||||
use hammond_data::pipeline;
|
||||
use hammond_data::FeedBuilder;
|
||||
use hammond_data::Source;
|
||||
// use hammond_data::errors::*;
|
||||
|
||||
use std::io::BufReader;
|
||||
|
||||
// RSS feeds
|
||||
const INTERCEPTED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-Intercepted.xml");
|
||||
const INTERCEPTED_URL: &str = "https://web.archive.org/web/20180120083840if_/https://feeds.\
|
||||
feedburner.com/InterceptedWithJeremyScahill";
|
||||
|
||||
const UNPLUGGED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-LinuxUnplugged.xml");
|
||||
const UNPLUGGED_URL: &str =
|
||||
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged";
|
||||
|
||||
const TIPOFF: &[u8] = include_bytes!("../tests/feeds/2018-01-20-TheTipOff.xml");
|
||||
const TIPOFF_URL: &str =
|
||||
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
|
||||
// This feed has HUGE descripion and summary fields which can be very
|
||||
// very expensive to parse.
|
||||
const CODE: &[u8] = include_bytes!("../tests/feeds/2018-01-20-GreaterThanCode.xml");
|
||||
const CODE_URL: &str =
|
||||
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast";
|
||||
|
||||
// Relative small feed
|
||||
const STARS: &[u8] = include_bytes!("../tests/feeds/2018-01-20-StealTheStars.xml");
|
||||
const STARS_URL: &str =
|
||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
|
||||
|
||||
static FEEDS: &[(&[u8], &str)] = &[
|
||||
(INTERCEPTED, INTERCEPTED_URL),
|
||||
(UNPLUGGED, UNPLUGGED_URL),
|
||||
(TIPOFF, TIPOFF_URL),
|
||||
(CODE, CODE_URL),
|
||||
(STARS, STARS_URL),
|
||||
];
|
||||
|
||||
fn bench_index_large_feed(c: &mut Criterion) {
|
||||
truncate_db().unwrap();
|
||||
let url = "https://www.greaterthancode.com/feed/podcast";
|
||||
let mut core = Core::new().unwrap();
|
||||
|
||||
c.bench_function("index_large_feed", move |b| {
|
||||
b.iter(|| {
|
||||
let s = Source::from_url(url).unwrap();
|
||||
// parse it into a channel
|
||||
let chan = rss::Channel::read_from(BufReader::new(CODE)).unwrap();
|
||||
let feed = FeedBuilder::default()
|
||||
.channel(chan)
|
||||
.source_id(s.id())
|
||||
.build()
|
||||
.unwrap();
|
||||
core.run(feed.index()).unwrap();
|
||||
})
|
||||
});
|
||||
truncate_db().unwrap();
|
||||
}
|
||||
|
||||
fn bench_index_small_feed(c: &mut Criterion) {
|
||||
truncate_db().unwrap();
|
||||
let url = "https://rss.art19.com/steal-the-stars";
|
||||
let mut core = Core::new().unwrap();
|
||||
|
||||
c.bench_function("index_small_feed", move |b| {
|
||||
b.iter(|| {
|
||||
let s = Source::from_url(url).unwrap();
|
||||
// parse it into a channel
|
||||
let chan = rss::Channel::read_from(BufReader::new(STARS)).unwrap();
|
||||
let feed = FeedBuilder::default()
|
||||
.channel(chan)
|
||||
.source_id(s.id())
|
||||
.build()
|
||||
.unwrap();
|
||||
core.run(feed.index()).unwrap();
|
||||
})
|
||||
});
|
||||
truncate_db().unwrap();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed);
|
||||
criterion_main!(benches);
|
||||
@ -1,131 +0,0 @@
|
||||
use diesel;
|
||||
use diesel::r2d2;
|
||||
use diesel_migrations::RunMigrationsError;
|
||||
use hyper;
|
||||
use native_tls;
|
||||
use rss;
|
||||
use url;
|
||||
use xml;
|
||||
|
||||
use std::io;
|
||||
|
||||
use models::Source;
|
||||
|
||||
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
|
||||
#[derive(Fail, Debug)]
|
||||
pub struct HttpStatusError {
|
||||
url: String,
|
||||
status_code: hyper::StatusCode,
|
||||
context: String,
|
||||
}
|
||||
|
||||
impl HttpStatusError {
|
||||
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
|
||||
HttpStatusError {
|
||||
url,
|
||||
status_code: code,
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Fail, Debug)]
|
||||
pub enum DataError {
|
||||
#[fail(display = "SQL Query failed: {}", _0)]
|
||||
DieselResultError(#[cause] diesel::result::Error),
|
||||
#[fail(display = "Database Migration error: {}", _0)]
|
||||
DieselMigrationError(#[cause] RunMigrationsError),
|
||||
#[fail(display = "R2D2 error: {}", _0)]
|
||||
R2D2Error(#[cause] r2d2::Error),
|
||||
#[fail(display = "R2D2 Pool error: {}", _0)]
|
||||
R2D2PoolError(#[cause] r2d2::PoolError),
|
||||
#[fail(display = "Hyper Error: {}", _0)]
|
||||
HyperError(#[cause] hyper::Error),
|
||||
#[fail(display = "Failed to parse a url: {}", _0)]
|
||||
// TODO: print the url too
|
||||
UrlError(#[cause] url::ParseError),
|
||||
#[fail(display = "TLS Error: {}", _0)]
|
||||
TLSError(#[cause] native_tls::Error),
|
||||
#[fail(display = "IO Error: {}", _0)]
|
||||
IOError(#[cause] io::Error),
|
||||
#[fail(display = "RSS Error: {}", _0)]
|
||||
RssError(#[cause] rss::Error),
|
||||
#[fail(display = "XML Reader Error: {}", _0)]
|
||||
XmlReaderError(#[cause] xml::reader::Error),
|
||||
#[fail(display = "Error: {}", _0)]
|
||||
Bail(String),
|
||||
#[fail(display = "{}", _0)]
|
||||
HttpStatusGeneral(HttpStatusError),
|
||||
#[fail(display = "FIXME: This should be better")]
|
||||
F301(Source),
|
||||
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
|
||||
ParseEpisodeError { reason: String, parent_id: i32 },
|
||||
#[fail(display = "Episode was not changed and thus skipped.")]
|
||||
EpisodeNotChanged,
|
||||
}
|
||||
|
||||
impl From<RunMigrationsError> for DataError {
|
||||
fn from(err: RunMigrationsError) -> Self {
|
||||
DataError::DieselMigrationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for DataError {
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
DataError::DieselResultError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<r2d2::Error> for DataError {
|
||||
fn from(err: r2d2::Error) -> Self {
|
||||
DataError::R2D2Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<r2d2::PoolError> for DataError {
|
||||
fn from(err: r2d2::PoolError) -> Self {
|
||||
DataError::R2D2PoolError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hyper::Error> for DataError {
|
||||
fn from(err: hyper::Error) -> Self {
|
||||
DataError::HyperError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for DataError {
|
||||
fn from(err: url::ParseError) -> Self {
|
||||
DataError::UrlError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<native_tls::Error> for DataError {
|
||||
fn from(err: native_tls::Error) -> Self {
|
||||
DataError::TLSError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for DataError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
DataError::IOError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rss::Error> for DataError {
|
||||
fn from(err: rss::Error) -> Self {
|
||||
DataError::RssError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<xml::reader::Error> for DataError {
|
||||
fn from(err: xml::reader::Error) -> Self {
|
||||
DataError::XmlReaderError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DataError {
|
||||
fn from(err: String) -> Self {
|
||||
DataError::Bail(err)
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
#![recursion_limit = "1024"]
|
||||
#![allow(unknown_lints)]
|
||||
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(option_map_unit_fn))]
|
||||
#![cfg_attr(
|
||||
feature = "clippy",
|
||||
warn(
|
||||
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut,
|
||||
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else,
|
||||
items_after_statements, used_underscore_binding
|
||||
)
|
||||
)]
|
||||
#![warn(
|
||||
bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
|
||||
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals, path_statements,
|
||||
patterns_in_fns_without_body, plugin_as_library, private_in_public, private_no_mangle_fns,
|
||||
private_no_mangle_statics, safe_extern_statics, unconditional_recursion,
|
||||
unions_with_drop_fields, unused_allocation, unused_comparisons, unused_parens, while_true,
|
||||
missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts,
|
||||
unused_extern_crates, unused
|
||||
)]
|
||||
#![deny(warnings)]
|
||||
|
||||
//! FIXME: Docs
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate maplit;
|
||||
|
||||
#[macro_use]
|
||||
extern crate derive_builder;
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel_migrations;
|
||||
// #[macro_use]
|
||||
extern crate failure;
|
||||
#[macro_use]
|
||||
extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
extern crate ammonia;
|
||||
extern crate chrono;
|
||||
extern crate futures;
|
||||
extern crate hyper;
|
||||
extern crate hyper_tls;
|
||||
extern crate native_tls;
|
||||
extern crate num_cpus;
|
||||
extern crate rayon;
|
||||
extern crate rayon_futures;
|
||||
extern crate rfc822_sanitizer;
|
||||
extern crate rss;
|
||||
extern crate tokio_core;
|
||||
extern crate url;
|
||||
extern crate xdg;
|
||||
extern crate xml;
|
||||
|
||||
pub mod database;
|
||||
#[allow(missing_docs)]
|
||||
pub mod dbqueries;
|
||||
#[allow(missing_docs)]
|
||||
pub mod errors;
|
||||
mod feed;
|
||||
pub(crate) mod models;
|
||||
pub mod opml;
|
||||
mod parser;
|
||||
pub mod pipeline;
|
||||
mod schema;
|
||||
pub mod utils;
|
||||
|
||||
pub use feed::{Feed, FeedBuilder};
|
||||
pub use models::Save;
|
||||
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
|
||||
|
||||
// Set the user agent, See #53 for more
|
||||
// Keep this in sync with Tor-browser releases
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0";
|
||||
|
||||
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub mod xdg_dirs {
|
||||
use std::path::PathBuf;
|
||||
use xdg;
|
||||
|
||||
lazy_static!{
|
||||
pub(crate) static ref HAMMOND_XDG: xdg::BaseDirectories = {
|
||||
xdg::BaseDirectories::with_prefix("hammond").unwrap()
|
||||
};
|
||||
|
||||
/// XDG_DATA Directory `Pathbuf`.
|
||||
pub static ref HAMMOND_DATA: PathBuf = {
|
||||
HAMMOND_XDG.create_data_directory(HAMMOND_XDG.get_data_home()).unwrap()
|
||||
};
|
||||
|
||||
/// XDG_CONFIG Directory `Pathbuf`.
|
||||
pub static ref HAMMOND_CONFIG: PathBuf = {
|
||||
HAMMOND_XDG.create_config_directory(HAMMOND_XDG.get_config_home()).unwrap()
|
||||
};
|
||||
|
||||
/// XDG_CACHE Directory `Pathbuf`.
|
||||
pub static ref HAMMOND_CACHE: PathBuf = {
|
||||
HAMMOND_XDG.create_cache_directory(HAMMOND_XDG.get_cache_home()).unwrap()
|
||||
};
|
||||
|
||||
/// Hammond Download Direcotry `PathBuf`.
|
||||
pub static ref DL_DIR: PathBuf = {
|
||||
HAMMOND_XDG.create_data_directory("Downloads").unwrap()
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
mod new_episode;
|
||||
mod new_podcast;
|
||||
mod new_source;
|
||||
|
||||
mod episode;
|
||||
mod podcast;
|
||||
mod source;
|
||||
|
||||
// use futures::prelude::*;
|
||||
// use futures::future::*;
|
||||
|
||||
pub(crate) use self::episode::EpisodeCleanerQuery;
|
||||
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
|
||||
pub(crate) use self::new_podcast::NewPodcast;
|
||||
pub(crate) use self::new_source::NewSource;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::new_episode::NewEpisodeBuilder;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::new_podcast::NewPodcastBuilder;
|
||||
|
||||
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetQuery};
|
||||
pub use self::podcast::{Podcast, PodcastCoverQuery};
|
||||
pub use self::source::Source;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum IndexState<T> {
|
||||
Index(T),
|
||||
Update((T, i32)),
|
||||
NotChanged,
|
||||
}
|
||||
|
||||
pub trait Insert<T> {
|
||||
type Error;
|
||||
|
||||
fn insert(&self) -> Result<T, Self::Error>;
|
||||
}
|
||||
|
||||
pub trait Update<T> {
|
||||
type Error;
|
||||
|
||||
fn update(&self, i32) -> Result<T, Self::Error>;
|
||||
}
|
||||
|
||||
// This might need to change in the future
|
||||
pub trait Index<T>: Insert<T> + Update<T> {
|
||||
type Error;
|
||||
|
||||
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
|
||||
}
|
||||
|
||||
/// FIXME: DOCS
|
||||
pub trait Save<T> {
|
||||
/// The Error type to be returned.
|
||||
type Error;
|
||||
/// Helper method to easily save/"sync" current state of a diesel model to
|
||||
/// the Database.
|
||||
fn save(&self) -> Result<T, Self::Error>;
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
use diesel::SaveChangesDsl;
|
||||
|
||||
use database::connection;
|
||||
use errors::DataError;
|
||||
use models::{Save, Source};
|
||||
use schema::podcast;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
|
||||
#[belongs_to(Source, foreign_key = "source_id")]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[table_name = "podcast"]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model of the podcast table.
|
||||
pub struct Podcast {
|
||||
id: i32,
|
||||
title: String,
|
||||
link: String,
|
||||
description: String,
|
||||
image_uri: Option<String>,
|
||||
favorite: bool,
|
||||
archive: bool,
|
||||
always_dl: bool,
|
||||
source_id: i32,
|
||||
}
|
||||
|
||||
impl Save<Podcast> for Podcast {
|
||||
type Error = DataError;
|
||||
|
||||
/// Helper method to easily save/"sync" current state of self to the
|
||||
/// Database.
|
||||
fn save(&self) -> Result<Podcast, Self::Error> {
|
||||
let db = connection();
|
||||
let tempdb = db.get()?;
|
||||
|
||||
self.save_changes::<Podcast>(&*tempdb).map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl Podcast {
|
||||
/// Get the Feed `id`.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get the Feed `title`.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Get the Feed `link`.
|
||||
///
|
||||
/// Usually the website/homepage of the content creator.
|
||||
pub fn link(&self) -> &str {
|
||||
&self.link
|
||||
}
|
||||
|
||||
/// Set the Podcast/Feed `link`.
|
||||
pub fn set_link(&mut self, value: &str) {
|
||||
self.link = value.to_string();
|
||||
}
|
||||
|
||||
/// Get the `description`.
|
||||
pub fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
/// Set the `description`.
|
||||
pub fn set_description(&mut self, value: &str) {
|
||||
self.description = value.to_string();
|
||||
}
|
||||
|
||||
/// Get the `image_uri`.
|
||||
///
|
||||
/// Represents the uri(url usually) that the Feed cover image is located at.
|
||||
pub fn image_uri(&self) -> Option<&str> {
|
||||
self.image_uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set the `image_uri`.
|
||||
pub fn set_image_uri(&mut self, value: Option<&str>) {
|
||||
self.image_uri = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Represents the archiving policy for the episode.
|
||||
pub fn archive(&self) -> bool {
|
||||
self.archive
|
||||
}
|
||||
|
||||
/// Set the `archive` policy.
|
||||
pub fn set_archive(&mut self, b: bool) {
|
||||
self.archive = b
|
||||
}
|
||||
|
||||
/// Get the `favorite` status of the `Podcast` Feed.
|
||||
pub fn favorite(&self) -> bool {
|
||||
self.favorite
|
||||
}
|
||||
|
||||
/// Set `favorite` status.
|
||||
pub fn set_favorite(&mut self, b: bool) {
|
||||
self.favorite = b
|
||||
}
|
||||
|
||||
/// Represents the download policy for the `Podcast` Feed.
|
||||
///
|
||||
/// Reserved for the use with a Download manager, yet to be implemented.
|
||||
///
|
||||
/// If true Podcast Episode should be downloaded automaticly/skipping
|
||||
/// the selection queue.
|
||||
pub fn always_download(&self) -> bool {
|
||||
self.always_dl
|
||||
}
|
||||
|
||||
/// Set the download policy.
|
||||
pub fn set_always_download(&mut self, b: bool) {
|
||||
self.always_dl = b
|
||||
}
|
||||
|
||||
/// `Source` table foreign key.
|
||||
pub fn source_id(&self) -> i32 {
|
||||
self.source_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Clone)]
|
||||
/// Diesel Model of the podcast cover query.
|
||||
/// Used for fetching information about a Podcast's cover.
|
||||
pub struct PodcastCoverQuery {
|
||||
id: i32,
|
||||
title: String,
|
||||
image_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Podcast> for PodcastCoverQuery {
|
||||
fn from(p: Podcast) -> PodcastCoverQuery {
|
||||
PodcastCoverQuery {
|
||||
id: p.id(),
|
||||
title: p.title,
|
||||
image_uri: p.image_uri,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<Podcast>> for PodcastCoverQuery {
|
||||
fn from(p: Arc<Podcast>) -> PodcastCoverQuery {
|
||||
PodcastCoverQuery {
|
||||
id: p.id(),
|
||||
title: p.title.clone(),
|
||||
image_uri: p.image_uri.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PodcastCoverQuery {
|
||||
/// Get the Feed `id`.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get the Feed `title`.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Get the `image_uri`.
|
||||
///
|
||||
/// Represents the uri(url usually) that the Feed cover image is located at.
|
||||
pub fn image_uri(&self) -> Option<&str> {
|
||||
self.image_uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
use diesel::SaveChangesDsl;
|
||||
// use failure::ResultExt;
|
||||
use rss::Channel;
|
||||
use url::Url;
|
||||
|
||||
use hyper::client::HttpConnector;
|
||||
use hyper::header::{
|
||||
ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified, Location, UserAgent,
|
||||
};
|
||||
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
|
||||
use hyper_tls::HttpsConnector;
|
||||
|
||||
// use futures::future::ok;
|
||||
use futures::future::{loop_fn, Future, Loop};
|
||||
use futures::prelude::*;
|
||||
|
||||
use database::connection;
|
||||
use errors::*;
|
||||
use feed::{Feed, FeedBuilder};
|
||||
use models::{NewSource, Save};
|
||||
use schema::source;
|
||||
use USER_AGENT;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
|
||||
#[table_name = "source"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model of the source table.
|
||||
pub struct Source {
|
||||
id: i32,
|
||||
uri: String,
|
||||
last_modified: Option<String>,
|
||||
http_etag: Option<String>,
|
||||
}
|
||||
|
||||
impl Save<Source> for Source {
|
||||
type Error = DataError;
|
||||
|
||||
/// Helper method to easily save/"sync" current state of self to the
|
||||
/// Database.
|
||||
fn save(&self) -> Result<Source, Self::Error> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
self.save_changes::<Source>(&con).map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Get the source `id` column.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Represents the location(usually url) of the Feed xml file.
|
||||
pub fn uri(&self) -> &str {
|
||||
&self.uri
|
||||
}
|
||||
|
||||
/// Set the `uri` field value.
|
||||
pub fn set_uri(&mut self, uri: String) {
|
||||
self.uri = uri;
|
||||
}
|
||||
|
||||
/// Represents the Http Last-Modified Header field.
|
||||
///
|
||||
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
|
||||
pub fn last_modified(&self) -> Option<&str> {
|
||||
self.last_modified.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set `last_modified` value.
|
||||
pub fn set_last_modified(&mut self, value: Option<String>) {
|
||||
// self.last_modified = value.map(|x| x.to_string());
|
||||
self.last_modified = value;
|
||||
}
|
||||
|
||||
/// Represents the Http Etag Header field.
|
||||
///
|
||||
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
|
||||
pub fn http_etag(&self) -> Option<&str> {
|
||||
self.http_etag.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set `http_etag` value.
|
||||
pub fn set_http_etag(&mut self, value: Option<&str>) {
|
||||
self.http_etag = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Extract Etag and LastModifier from res, and update self and the
|
||||
/// corresponding db row.
|
||||
fn update_etag(&mut self, res: &Response) -> Result<(), DataError> {
|
||||
let headers = res.headers();
|
||||
|
||||
let etag = headers.get::<ETag>().map(|x| x.tag());
|
||||
let lmod = headers.get::<LastModified>().map(|x| format!("{}", x));
|
||||
|
||||
if (self.http_etag() != etag) || (self.last_modified != lmod) {
|
||||
self.set_http_etag(etag);
|
||||
self.set_last_modified(lmod);
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_err(self, context: &str, code: StatusCode) -> DataError {
|
||||
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
|
||||
}
|
||||
|
||||
// TODO match on more stuff
|
||||
// 301: Moved Permanently
|
||||
// 304: Up to date Feed, checked with the Etag
|
||||
// 307: Temporary redirect of the url
|
||||
// 308: Permanent redirect of the url
|
||||
// 401: Unathorized
|
||||
// 403: Forbidden
|
||||
// 408: Timeout
|
||||
// 410: Feed deleted
|
||||
// TODO: Rething this api,
|
||||
fn match_status(mut self, res: Response) -> Result<Response, DataError> {
|
||||
self.update_etag(&res)?;
|
||||
let code = res.status();
|
||||
|
||||
match code {
|
||||
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
|
||||
StatusCode::MovedPermanently => {
|
||||
error!("Feed was moved permanently.");
|
||||
self.handle_301(&res)?;
|
||||
return Err(DataError::F301(self));
|
||||
}
|
||||
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
|
||||
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
|
||||
StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)),
|
||||
StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)),
|
||||
StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)),
|
||||
StatusCode::RequestTimeout => return Err(self.make_err("408: Request Timeout.", code)),
|
||||
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
|
||||
_ => info!("HTTP StatusCode: {}", code),
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
|
||||
let headers = res.headers();
|
||||
|
||||
if let Some(url) = headers.get::<Location>() {
|
||||
self.set_uri(url.to_string());
|
||||
self.http_etag = None;
|
||||
self.last_modified = None;
|
||||
self.save()?;
|
||||
info!("Feed url was updated succesfully.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Construct a new `Source` with the given `uri` and index it.
|
||||
///
|
||||
/// This only indexes the `Source` struct, not the Podcast Feed.
|
||||
pub fn from_url(uri: &str) -> Result<Source, DataError> {
|
||||
let url = Url::parse(uri)?;
|
||||
|
||||
NewSource::new(&url).to_source()
|
||||
}
|
||||
|
||||
/// `Feed` constructor.
|
||||
///
|
||||
/// Fetches the latest xml Feed.
|
||||
///
|
||||
/// Updates the validator Http Headers.
|
||||
///
|
||||
/// Consumes `self` and Returns the corresponding `Feed` Object.
|
||||
// Refactor into TryInto once it lands on stable.
|
||||
pub fn into_feed(
|
||||
self,
|
||||
client: Client<HttpsConnector<HttpConnector>>,
|
||||
ignore_etags: bool,
|
||||
) -> impl Future<Item = Feed, Error = DataError> {
|
||||
let id = self.id();
|
||||
let response = loop_fn(self, move |source| {
|
||||
source
|
||||
.request_constructor(&client.clone(), ignore_etags)
|
||||
.then(|res| match res {
|
||||
Ok(response) => Ok(Loop::Break(response)),
|
||||
Err(err) => match err {
|
||||
DataError::F301(s) => {
|
||||
info!("Following redirect...");
|
||||
Ok(Loop::Continue(s))
|
||||
}
|
||||
e => Err(e),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
response
|
||||
.and_then(response_to_channel)
|
||||
.and_then(move |chan| {
|
||||
FeedBuilder::default()
|
||||
.channel(chan)
|
||||
.source_id(id)
|
||||
.build()
|
||||
.map_err(From::from)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: make ignore_etags an Enum for better ergonomics.
|
||||
// #bools_are_just_2variant_enmus
|
||||
fn request_constructor(
|
||||
self,
|
||||
client: &Client<HttpsConnector<HttpConnector>>,
|
||||
ignore_etags: bool,
|
||||
) -> impl Future<Item = Response, Error = DataError> {
|
||||
// FIXME: remove unwrap somehow
|
||||
let uri = Uri::from_str(self.uri()).unwrap();
|
||||
let mut req = Request::new(Method::Get, uri);
|
||||
|
||||
// Set the UserAgent cause ppl still seem to check it for some reason...
|
||||
req.headers_mut().set(UserAgent::new(USER_AGENT));
|
||||
|
||||
if !ignore_etags {
|
||||
if let Some(etag) = self.http_etag() {
|
||||
let tag = vec![EntityTag::new(true, etag.to_owned())];
|
||||
req.headers_mut().set(IfNoneMatch::Items(tag));
|
||||
}
|
||||
|
||||
if let Some(lmod) = self.last_modified() {
|
||||
if let Ok(date) = lmod.parse::<HttpDate>() {
|
||||
req.headers_mut().set(IfModifiedSince(date));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.request(req)
|
||||
.map_err(From::from)
|
||||
.and_then(move |res| self.match_status(res))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(needless_pass_by_value)]
|
||||
fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = DataError> + Send {
|
||||
res.body()
|
||||
.concat2()
|
||||
.map(|x| x.into_iter())
|
||||
.map_err(From::from)
|
||||
.map(|iter| iter.collect::<Vec<u8>>())
|
||||
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
|
||||
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio_core::reactor::Core;
|
||||
|
||||
use database::truncate_db;
|
||||
use utils::get_feed;
|
||||
|
||||
#[test]
|
||||
fn test_into_feed() {
|
||||
truncate_db().unwrap();
|
||||
|
||||
let mut core = Core::new().unwrap();
|
||||
let client = Client::configure()
|
||||
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
|
||||
.build(&core.handle());
|
||||
|
||||
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill";
|
||||
let source = Source::from_url(url).unwrap();
|
||||
let id = source.id();
|
||||
|
||||
let feed = source.into_feed(client, true);
|
||||
let feed = core.run(feed).unwrap();
|
||||
|
||||
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
|
||||
assert_eq!(expected, feed);
|
||||
}
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
//! FIXME: Docs
|
||||
|
||||
// #![allow(unused)]
|
||||
|
||||
use errors::DataError;
|
||||
use models::Source;
|
||||
use xml::reader;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
// use std::fs::{File, OpenOptions};
|
||||
// use std::io::BufReader;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
// FIXME: Make it a Diesel model
|
||||
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
|
||||
/// not `RSS` related sub-elements are ommited.
|
||||
///
|
||||
/// [spec]: http://dev.opml.org/spec2.html
|
||||
pub struct Opml {
|
||||
title: String,
|
||||
description: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Import feed url's from a `R` into the `Source` table.
|
||||
// TODO: Write test
|
||||
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
|
||||
let feeds = extract_sources(reader)?
|
||||
.iter()
|
||||
.map(|opml| Source::from_url(&opml.url))
|
||||
.filter_map(|s| {
|
||||
if let Err(ref err) = s {
|
||||
let txt = "If you think this might be a bug please consider filling a report over \
|
||||
at https://gitlab.gnome.org/World/hammond/issues/new";
|
||||
|
||||
error!("Failed to import a Show: {}", err);
|
||||
error!("{}", txt);
|
||||
}
|
||||
|
||||
s.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(feeds)
|
||||
}
|
||||
|
||||
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
|
||||
/// return the new `Source`s
|
||||
// TODO: Write test
|
||||
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
|
||||
let content = fs::read(path)?;
|
||||
import_to_db(content.as_slice()).map_err(From::from)
|
||||
}
|
||||
|
||||
/// Extracts the `outline` elemnts from a reader `R` and returns a `HashSet` of `Opml` structs.
|
||||
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
|
||||
let mut list = HashSet::new();
|
||||
let parser = reader::EventReader::new(reader);
|
||||
|
||||
parser
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
Ok(reader::XmlEvent::StartElement {
|
||||
name, attributes, ..
|
||||
}) => {
|
||||
if name.local_name == "outline" {
|
||||
let mut title = String::new();
|
||||
let mut url = String::new();
|
||||
let mut description = String::new();
|
||||
|
||||
attributes.into_iter().for_each(|attribute| {
|
||||
match attribute.name.local_name.as_str() {
|
||||
"title" => title = attribute.value,
|
||||
"xmlUrl" => url = attribute.value,
|
||||
"description" => description = attribute.value,
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
let feed = Opml {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
};
|
||||
list.insert(feed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
_ => Ok(()),
|
||||
})
|
||||
.collect::<Result<Vec<_>, reader::Error>>()?;
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Local;
|
||||
|
||||
#[test]
|
||||
fn test_extract() {
|
||||
let int_title = String::from("Intercepted with Jeremy Scahill");
|
||||
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
|
||||
let int_desc =
|
||||
String::from(
|
||||
"The people behind The Intercept’s fearless reporting and incisive \
|
||||
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss the \
|
||||
crucial issues of our time: national security, civil liberties, foreign policy, \
|
||||
and criminal justice. Plus interviews with artists, thinkers, and newsmakers \
|
||||
who challenge our preconceptions about the world we live in.",
|
||||
);
|
||||
|
||||
let dec_title = String::from("Deconstructed with Mehdi Hasan");
|
||||
let dec_url = String::from("https://rss.prod.firstlook.media/deconstructed/podcast.rss");
|
||||
let dec_desc = String::from(
|
||||
"Journalist Mehdi Hasan is known around the world for his televised takedowns of \
|
||||
presidents and prime ministers. In this new podcast from The Intercept, Mehdi \
|
||||
unpacks a game-changing news event of the week while challenging the conventional \
|
||||
wisdom. As a Brit, a Muslim and an immigrant based in Donald Trump's Washington \
|
||||
D.C., Mehdi gives a refreshingly provocative perspective on the ups and downs of \
|
||||
American—and global—politics.",
|
||||
);
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
let sample1 = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
|
||||
<opml version=\"2.0\"> \
|
||||
<head> \
|
||||
<title>Test OPML File</title> \
|
||||
<dateCreated>{}</dateCreated> \
|
||||
<docs>http://www.opml.org/spec2</docs> \
|
||||
</head> \
|
||||
<body> \
|
||||
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
|
||||
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
|
||||
</body> \
|
||||
</opml>",
|
||||
Local::now().format("%a, %d %b %Y %T %Z"),
|
||||
int_title,
|
||||
int_desc,
|
||||
int_url,
|
||||
dec_title,
|
||||
dec_desc,
|
||||
dec_url,
|
||||
);
|
||||
|
||||
let map = hashset![
|
||||
Opml {
|
||||
title: int_title,
|
||||
description: int_desc,
|
||||
url: int_url
|
||||
},
|
||||
Opml {
|
||||
title: dec_title,
|
||||
description: dec_desc,
|
||||
url: dec_url
|
||||
},
|
||||
];
|
||||
assert_eq!(extract_sources(sample1.as_bytes()).unwrap(), map);
|
||||
}
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
// FIXME:
|
||||
//! Docs.
|
||||
|
||||
use futures::future::*;
|
||||
use futures::prelude::*;
|
||||
use futures::stream::*;
|
||||
|
||||
use hyper::client::HttpConnector;
|
||||
use hyper::Client;
|
||||
use hyper_tls::HttpsConnector;
|
||||
use tokio_core::reactor::Core;
|
||||
|
||||
use num_cpus;
|
||||
use rayon;
|
||||
use rayon_futures::ScopeFutureExt;
|
||||
|
||||
use errors::DataError;
|
||||
use Source;
|
||||
|
||||
// use std::sync::{Arc, Mutex};
|
||||
|
||||
// http://gtk-rs.org/tuto/closures
|
||||
#[macro_export]
|
||||
macro_rules! clone {
|
||||
(@param _) => ( _ );
|
||||
(@param $x:ident) => ( $x );
|
||||
($($n:ident),+ => move || $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move || $body
|
||||
}
|
||||
);
|
||||
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move |$(clone!(@param $p),)+| $body
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
|
||||
|
||||
/// The pipline to be run for indexing and updating a Podcast feed that originates from
|
||||
/// `Source.uri`.
|
||||
///
|
||||
/// Messy temp diagram:
|
||||
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
|
||||
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
|
||||
pub fn pipeline<'a, S>(
|
||||
sources: S,
|
||||
ignore_etags: bool,
|
||||
client: &HttpsClient,
|
||||
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a
|
||||
where
|
||||
S: Stream<Item = Source, Error = DataError> + 'a,
|
||||
{
|
||||
sources
|
||||
.and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags)))
|
||||
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index())))
|
||||
// the stream will stop at the first error so
|
||||
// we ensure that everything will succeded regardless.
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.then(|_| ok::<(), DataError>(()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
|
||||
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
|
||||
pub fn run<S>(sources: S, ignore_etags: bool) -> Result<(), DataError>
|
||||
where
|
||||
S: IntoIterator<Item = Source>,
|
||||
{
|
||||
let mut core = Core::new()?;
|
||||
let handle = core.handle();
|
||||
let client = Client::configure()
|
||||
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
|
||||
.build(&handle);
|
||||
|
||||
let stream = iter_ok::<_, DataError>(sources);
|
||||
let p = pipeline(stream, ignore_etags, &client);
|
||||
core.run(p).map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use database::truncate_db;
|
||||
use dbqueries;
|
||||
use Source;
|
||||
|
||||
// (path, url) tuples.
|
||||
const URLS: &[&str] = &[
|
||||
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill",
|
||||
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
|
||||
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
|
||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars",
|
||||
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
|
||||
com/feed/podcast",
|
||||
];
|
||||
|
||||
#[test]
|
||||
/// Insert feeds and update/index them.
|
||||
fn test_pipeline() {
|
||||
truncate_db().unwrap();
|
||||
let bad_url = "https://gitlab.gnome.org/World/hammond.atom";
|
||||
// if a stream returns error/None it stops
|
||||
// bad we want to parse all feeds regardless if one fails
|
||||
Source::from_url(bad_url).unwrap();
|
||||
|
||||
URLS.iter().for_each(|url| {
|
||||
// Index the urls into the source table.
|
||||
Source::from_url(url).unwrap();
|
||||
});
|
||||
|
||||
let sources = dbqueries::get_sources().unwrap();
|
||||
run(sources, true).unwrap();
|
||||
|
||||
let sources = dbqueries::get_sources().unwrap();
|
||||
// Run again to cover Unique constrains erros.
|
||||
run(sources, true).unwrap();
|
||||
|
||||
// Assert the index rows equal the controlled results
|
||||
assert_eq!(dbqueries::get_sources().unwrap().len(), 6);
|
||||
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
|
||||
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
[package]
|
||||
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
|
||||
name = "hammond-downloader"
|
||||
version = "0.1.0"
|
||||
workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
error-chain = "0.11.0"
|
||||
hyper = "0.11.27"
|
||||
log = "0.4.1"
|
||||
mime_guess = "1.8.4"
|
||||
reqwest = "0.8.5"
|
||||
tempdir = "0.3.7"
|
||||
glob = "0.2.11"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
|
||||
[dependencies.hammond-data]
|
||||
path = "../hammond-data"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "0.5.1"
|
||||
@ -1,41 +0,0 @@
|
||||
use hammond_data::errors::DataError;
|
||||
use reqwest;
|
||||
use std::io;
|
||||
|
||||
#[derive(Fail, Debug)]
|
||||
pub enum DownloadError {
|
||||
#[fail(display = "Reqwest error: {}", _0)]
|
||||
RequestError(#[cause] reqwest::Error),
|
||||
#[fail(display = "Data error: {}", _0)]
|
||||
DataError(#[cause] DataError),
|
||||
#[fail(display = "Io error: {}", _0)]
|
||||
IoError(#[cause] io::Error),
|
||||
#[fail(display = "Unexpected server response: {}", _0)]
|
||||
UnexpectedResponse(reqwest::StatusCode),
|
||||
#[fail(display = "The Download was cancelled.")]
|
||||
DownloadCancelled,
|
||||
#[fail(display = "Remote Image location not found.")]
|
||||
NoImageLocation,
|
||||
#[fail(display = "Failed to parse CacheLocation.")]
|
||||
InvalidCacheLocation,
|
||||
#[fail(display = "Failed to parse Cached Image Location.")]
|
||||
InvalidCachedImageLocation,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for DownloadError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
DownloadError::RequestError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for DownloadError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
DownloadError::IoError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DataError> for DownloadError {
|
||||
fn from(err: DataError) -> Self {
|
||||
DownloadError::DataError(err)
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
#![recursion_limit = "1024"]
|
||||
#![warn(unused_extern_crates, unused)]
|
||||
#![allow(unknown_lints)]
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name, option_map_unit_fn))]
|
||||
#![deny(warnings)]
|
||||
|
||||
extern crate failure;
|
||||
#[macro_use]
|
||||
extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
extern crate glob;
|
||||
extern crate hammond_data;
|
||||
extern crate hyper;
|
||||
extern crate mime_guess;
|
||||
extern crate reqwest;
|
||||
extern crate tempdir;
|
||||
|
||||
pub mod downloader;
|
||||
pub mod errors;
|
||||
@ -1,45 +0,0 @@
|
||||
[package]
|
||||
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
|
||||
build = "build.rs"
|
||||
name = "hammond-gtk"
|
||||
version = "0.1.0"
|
||||
workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.2"
|
||||
crossbeam-channel = "0.1.2"
|
||||
gdk = "0.8.0"
|
||||
gdk-pixbuf = "0.4.0"
|
||||
glib = "0.5.0"
|
||||
humansize = "1.1.0"
|
||||
lazy_static = "1.0.0"
|
||||
log = "0.4.1"
|
||||
loggerv = "0.7.1"
|
||||
open = "1.2.1"
|
||||
rayon = "1.0.1"
|
||||
send-cell = "0.1.3"
|
||||
url = "1.7.0"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
take_mut = "0.2.2"
|
||||
regex = "1.0.0"
|
||||
reqwest = "0.8.5"
|
||||
serde_json = "1.0.17"
|
||||
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
|
||||
|
||||
[dependencies.gtk]
|
||||
features = ["v3_22"]
|
||||
version = "0.4.1"
|
||||
|
||||
[dependencies.gio]
|
||||
features = ["v2_50"]
|
||||
version = "0.4.0"
|
||||
|
||||
[dependencies.hammond-data]
|
||||
path = "../hammond-data"
|
||||
|
||||
[dependencies.hammond-downloader]
|
||||
path = "../hammond-downloader"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "0.5.1"
|
||||
@ -1,9 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
Command::new("glib-compile-resources")
|
||||
.args(&["--generate", "resources.xml"])
|
||||
.current_dir("resources")
|
||||
.status()
|
||||
.unwrap();
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.0 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<object class="GtkBox" id="empty_show">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="vexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="pixel_size">128</property>
|
||||
<property name="icon_name">application-rss+xml-symbolic</property>
|
||||
<property name="use_fallback">True</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">This show does not have any episodes</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.4399999999999999"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">If you think this is an Error, Plese consider opening a bug report.</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,91 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.21.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
This file is part of Hammond.
|
||||
|
||||
Hammond is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Hammond is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors:
|
||||
Jordan Petridis
|
||||
Tobias Bernard
|
||||
|
||||
-->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<!-- interface-license-type gplv3 -->
|
||||
<!-- interface-name Hammond -->
|
||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="empty_view">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="vexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="pixel_size">128</property>
|
||||
<property name="icon_name">application-rss+xml-symbolic</property>
|
||||
<property name="use_fallback">True</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">No Feed Subscription Found</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.4399999999999999"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">You can subscribe to feeds using the "+" button</property>
|
||||
<style>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,399 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
This file is part of Hammond.
|
||||
|
||||
Hammond is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Hammond is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors:
|
||||
Jordan Petridis
|
||||
Tobias Bernard
|
||||
|
||||
-->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<!-- interface-license-type gplv3 -->
|
||||
<!-- interface-name Hammond -->
|
||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="container">
|
||||
<property name="name">container</property>
|
||||
<property name="width_request">400</property>
|
||||
<property name="height_request">600</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<property name="name">scrolled_window</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<child>
|
||||
<object class="GtkViewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="frame_parent">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">32</property>
|
||||
<property name="margin_right">32</property>
|
||||
<property name="margin_top">32</property>
|
||||
<property name="margin_bottom">32</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">24</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="today_box">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Today</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.5"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="today_list">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="yday_box">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">Yesterday</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.5"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="yday_list">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="week_box">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">This Week</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.5"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="week_list">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="month_box">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="label" translatable="yes">This Month</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.5"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="month_list">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="rest_box">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label" translatable="yes">Older</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="scale" value="1.5"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="rest_list">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">5</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,300 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
This file is part of Hammond.
|
||||
|
||||
Hammond is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Hammond is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors:
|
||||
Jordan Petridis
|
||||
Tobias Bernard
|
||||
|
||||
-->
|
||||
<interface domain="">
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<!-- interface-license-type gplv3 -->
|
||||
<!-- interface-name Hammond -->
|
||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="container">
|
||||
<property name="width_request">400</property>
|
||||
<property name="height_request">600</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<property name="name">scrolled_window</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="vexpand">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<child>
|
||||
<object class="GtkViewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="can_default">True</property>
|
||||
<property name="hexpand">True</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">32</property>
|
||||
<property name="margin_right">32</property>
|
||||
<property name="margin_top">32</property>
|
||||
<property name="margin_bottom">32</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">24</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkImage" id="cover">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="pixel_size">256</property>
|
||||
<property name="icon_name">image-x-generic-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<property name="min_content_height">80</property>
|
||||
<child>
|
||||
<object class="GtkViewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="shadow_type">none</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="description">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="label">This is embarrasing!
|
||||
Sorry, we could not find a description for this Show.</property>
|
||||
<property name="use_markup">True</property>
|
||||
<property name="justify">center</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max_width_chars">70</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkMenuButton" id="settings_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="icon_name">emblem-system-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="link_button">
|
||||
<property name="label" translatable="yes">Website</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="padding">5</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="unsub_button">
|
||||
<property name="label" translatable="yes">Unsubscribe</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<style>
|
||||
<class name="destructive-action"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="padding">5</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="episodes">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<object class="GtkPopover" id="show_menu">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="position">bottom</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">6</property>
|
||||
<property name="margin_right">6</property>
|
||||
<property name="margin_top">6</property>
|
||||
<property name="margin_bottom">6</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkModelButton" id="mark_all_watched">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="text" translatable="yes">Mark all episodes as listened</property>
|
||||
<property name="centered">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
This file is part of Hammond.
|
||||
|
||||
Hammond is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Hammond is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors:
|
||||
Jordan Petridis
|
||||
Tobias Bernard
|
||||
|
||||
-->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<!-- interface-license-type gplv3 -->
|
||||
<!-- interface-name Hammond -->
|
||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="fb_child">
|
||||
<property name="width_request">256</property>
|
||||
<property name="height_request">256</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkImage" id="pd_cover">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="pixel_size">256</property>
|
||||
<property name="icon_name">image-x-generic-symbolic</property>
|
||||
<property name="icon_size">0</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,72 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.21.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
This file is part of Hammond.
|
||||
|
||||
Hammond is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Hammond is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors:
|
||||
Jordan Petridis
|
||||
Tobias Bernard
|
||||
|
||||
-->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<!-- interface-license-type gplv3 -->
|
||||
<!-- interface-name Hammond -->
|
||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="fb_parent">
|
||||
<property name="name">fb_parent</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<property name="name">scrolled_window</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<child>
|
||||
<object class="GtkViewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkFlowBox" id="flowbox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="margin_top">24</property>
|
||||
<property name="margin_bottom">24</property>
|
||||
<property name="homogeneous">True</property>
|
||||
<property name="column_spacing">12</property>
|
||||
<property name="row_spacing">12</property>
|
||||
<property name="max_children_per_line">20</property>
|
||||
<property name="selection_mode">none</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@ -1,11 +0,0 @@
|
||||
row {
|
||||
border-bottom: solid 1px rgba(0,0,0, 0.1);
|
||||
}
|
||||
|
||||
row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
list, border {
|
||||
border-radius: 4px;
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
# subdir('icons')
|
||||
|
||||
install_data('org.gnome.Hammond.desktop', install_dir : datadir + '/applications')
|
||||
install_data('org.gnome.Hammond.appdata.xml', install_dir : datadir + '/appdata')
|
||||
install_data('org.gnome.Hammond.gschema.xml', install_dir: join_paths(datadir, 'glib-2.0', 'schemas'))
|
||||
meson.add_install_script('../../scripts/compile-gschema.py')
|
||||
@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop">
|
||||
<id>org.gnome.Hammond</id>
|
||||
<name>Hammond</name>
|
||||
<project_license>GPL-3.0</project_license>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<developer_name>Jordan Petridis</developer_name>
|
||||
<summary>Modern Podcast Client for the GNOME desktop</summary>
|
||||
<description xml:lang="en">
|
||||
<p>
|
||||
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
|
||||
desktop written in Rust.
|
||||
</p>
|
||||
</description>
|
||||
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
|
||||
<screenshots>
|
||||
<screenshot>
|
||||
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image>
|
||||
<caption>Page 1</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/shows_view.png</image>
|
||||
<caption>Page 2</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/show_widget.png</image>
|
||||
<caption>Page 3</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.3.3" date="2018-05-19"/>
|
||||
</releases>
|
||||
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
|
||||
<update_contact>jpetridis@gnome.org</update_contact>
|
||||
<developer_name>Jordan Petridis and others</developer_name>
|
||||
</component>
|
||||
@ -1,11 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Hammond
|
||||
GenericName=Podcast Client
|
||||
Comment=Play, Subscribe and Manage Podcast Feeds.
|
||||
Icon=multimedia-player
|
||||
Exec=hammond
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
Categories=AudioVideo;Audio;Video;
|
||||
Keywords=Podcast
|
||||
@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/org/gnome/hammond/">
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_show.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_view.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_child.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file>
|
||||
<file compressed="true">gtk/style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@ -1,226 +0,0 @@
|
||||
#![allow(new_without_default)]
|
||||
|
||||
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt};
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::SettingsExt as GtkSettingsExt;
|
||||
|
||||
use hammond_data::Podcast;
|
||||
|
||||
use appnotif::{InAppNotification, UndoState};
|
||||
use headerbar::Header;
|
||||
use settings::{self, WindowGeometry};
|
||||
use stacks::{Content, PopulatedState};
|
||||
use utils;
|
||||
use widgets::{mark_all_notif, remove_show_notif};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
RefreshAllViews,
|
||||
RefreshEpisodesView,
|
||||
RefreshEpisodesViewBGR,
|
||||
RefreshShowsView,
|
||||
ReplaceWidget(Arc<Podcast>),
|
||||
RefreshWidgetIfSame(i32),
|
||||
ShowWidgetAnimated,
|
||||
ShowShowsAnimated,
|
||||
HeaderBarShowTile(String),
|
||||
HeaderBarNormal,
|
||||
HeaderBarShowUpdateIndicator,
|
||||
HeaderBarHideUpdateIndicator,
|
||||
MarkAllPlayerNotification(Arc<Podcast>),
|
||||
RemoveShow(Arc<Podcast>),
|
||||
ErrorNotification(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
app_instance: gtk::Application,
|
||||
window: gtk::Window,
|
||||
overlay: gtk::Overlay,
|
||||
header: Rc<Header>,
|
||||
content: Rc<Content>,
|
||||
receiver: Receiver<Action>,
|
||||
sender: Sender<Action>,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> App {
|
||||
let settings = Settings::new("org.gnome.Hammond");
|
||||
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
|
||||
.expect("Application Initialization failed...");
|
||||
|
||||
// Weird magic I copy-pasted that sets the Application Name in the Shell.
|
||||
glib::set_application_name("Hammond");
|
||||
glib::set_prgname(Some("Hammond"));
|
||||
|
||||
let cleanup_date = settings::get_cleanup_date(&settings);
|
||||
utils::cleanup(cleanup_date);
|
||||
|
||||
// Create the main window
|
||||
let window = gtk::Window::new(gtk::WindowType::Toplevel);
|
||||
window.set_title("Hammond");
|
||||
|
||||
window.connect_delete_event(clone!(application, settings, window => move |_, _| {
|
||||
WindowGeometry::from_window(&window).write(&settings);
|
||||
application.quit();
|
||||
Inhibit(false)
|
||||
}));
|
||||
|
||||
let (sender, receiver) = channel();
|
||||
|
||||
// Create a content instance
|
||||
let content =
|
||||
Rc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
|
||||
|
||||
// Create the headerbar
|
||||
let header = Rc::new(Header::new(&content, &window, &sender));
|
||||
|
||||
// Add the content main stack to the overlay.
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.add(&content.get_stack());
|
||||
|
||||
// Add the overlay to the main window
|
||||
window.add(&overlay);
|
||||
|
||||
App {
|
||||
app_instance: application,
|
||||
window,
|
||||
overlay,
|
||||
header,
|
||||
content,
|
||||
receiver,
|
||||
sender,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_timed_callbacks(&self) {
|
||||
self.setup_dark_theme();
|
||||
self.setup_refresh_on_startup();
|
||||
self.setup_auto_refresh();
|
||||
}
|
||||
|
||||
fn setup_dark_theme(&self) {
|
||||
let settings = gtk::Settings::get_default().unwrap();
|
||||
let enabled = self.settings.get_boolean("dark-theme");
|
||||
|
||||
settings.set_property_gtk_application_prefer_dark_theme(enabled);
|
||||
}
|
||||
|
||||
fn setup_refresh_on_startup(&self) {
|
||||
// Update the feeds right after the Application is initialized.
|
||||
if self.settings.get_boolean("refresh-on-startup") {
|
||||
let sender = self.sender.clone();
|
||||
|
||||
info!("Refresh on startup.");
|
||||
// The ui loads async, after initialization
|
||||
// so we need to delay this a bit so it won't block
|
||||
// requests that will come from loading the gui on startup.
|
||||
gtk::timeout_add(1500, move || {
|
||||
let s: Option<Vec<_>> = None;
|
||||
utils::refresh(s, sender.clone());
|
||||
glib::Continue(false)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_auto_refresh(&self) {
|
||||
let refresh_interval = settings::get_refresh_interval(&self.settings).num_seconds() as u32;
|
||||
let sender = self.sender.clone();
|
||||
|
||||
info!("Auto-refresh every {:?} seconds.", refresh_interval);
|
||||
|
||||
gtk::timeout_add_seconds(refresh_interval, move || {
|
||||
let s: Option<Vec<_>> = None;
|
||||
utils::refresh(s, sender.clone());
|
||||
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn run(self) {
|
||||
WindowGeometry::from_settings(&self.settings).apply(&self.window);
|
||||
|
||||
let window = self.window.clone();
|
||||
self.app_instance.connect_startup(move |app| {
|
||||
build_ui(&window, app);
|
||||
});
|
||||
|
||||
self.setup_timed_callbacks();
|
||||
|
||||
let content = self.content;
|
||||
let headerbar = self.header;
|
||||
let sender = self.sender;
|
||||
let overlay = self.overlay;
|
||||
let receiver = self.receiver;
|
||||
gtk::timeout_add(50, move || {
|
||||
match receiver.try_recv() {
|
||||
Ok(Action::RefreshAllViews) => content.update(),
|
||||
Ok(Action::RefreshShowsView) => content.update_shows_view(),
|
||||
Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id),
|
||||
Ok(Action::RefreshEpisodesView) => content.update_home(),
|
||||
Ok(Action::RefreshEpisodesViewBGR) => content.update_home_if_background(),
|
||||
Ok(Action::ReplaceWidget(pd)) => {
|
||||
let shows = content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.replace_widget(pd.clone())
|
||||
.map_err(|err| error!("Failed to update ShowWidget: {}", err))
|
||||
.map_err(|_| error!("Failed ot update ShowWidget {}", pd.title()))
|
||||
.ok();
|
||||
}
|
||||
Ok(Action::ShowWidgetAnimated) => {
|
||||
let shows = content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut().switch_visible(
|
||||
PopulatedState::Widget,
|
||||
gtk::StackTransitionType::SlideLeft,
|
||||
);
|
||||
}
|
||||
Ok(Action::ShowShowsAnimated) => {
|
||||
let shows = content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.switch_visible(PopulatedState::View, gtk::StackTransitionType::SlideRight);
|
||||
}
|
||||
Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title),
|
||||
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
|
||||
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
|
||||
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
|
||||
Ok(Action::MarkAllPlayerNotification(pd)) => {
|
||||
let notif = mark_all_notif(pd, &sender);
|
||||
notif.show(&overlay);
|
||||
}
|
||||
Ok(Action::RemoveShow(pd)) => {
|
||||
let notif = remove_show_notif(pd, sender.clone());
|
||||
notif.show(&overlay);
|
||||
}
|
||||
Ok(Action::ErrorNotification(err)) => {
|
||||
error!("An error notification was triggered: {}", err);
|
||||
let callback = || glib::Continue(false);
|
||||
let notif = InAppNotification::new(&err, callback, || {}, UndoState::Hidden);
|
||||
notif.show(&overlay);
|
||||
}
|
||||
Err(_) => (),
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
|
||||
ApplicationExtManual::run(&self.app_instance, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ui(window: >k::Window, app: >k::Application) {
|
||||
window.set_application(app);
|
||||
window.show_all();
|
||||
window.activate();
|
||||
app.connect_activate(move |_| ());
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum UndoState {
|
||||
Shown,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InAppNotification {
|
||||
revealer: gtk::Revealer,
|
||||
text: gtk::Label,
|
||||
undo: gtk::Button,
|
||||
close: gtk::Button,
|
||||
}
|
||||
|
||||
impl Default for InAppNotification {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/inapp_notif.ui");
|
||||
|
||||
let revealer: gtk::Revealer = builder.get_object("revealer").unwrap();
|
||||
let text: gtk::Label = builder.get_object("text").unwrap();
|
||||
let undo: gtk::Button = builder.get_object("undo").unwrap();
|
||||
let close: gtk::Button = builder.get_object("close").unwrap();
|
||||
|
||||
InAppNotification {
|
||||
revealer,
|
||||
text,
|
||||
undo,
|
||||
close,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InAppNotification {
|
||||
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self
|
||||
where
|
||||
F: FnMut() -> glib::Continue + 'static,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let notif = InAppNotification::default();
|
||||
notif.text.set_text(&text);
|
||||
|
||||
let revealer = notif.revealer.clone();
|
||||
let id = timeout_add_seconds(6, move || {
|
||||
revealer.set_reveal_child(false);
|
||||
callback()
|
||||
});
|
||||
let id = Rc::new(RefCell::new(Some(id)));
|
||||
|
||||
// Cancel the callback
|
||||
let revealer = notif.revealer.clone();
|
||||
notif.undo.connect_clicked(move |_| {
|
||||
let foo = id.borrow_mut().take();
|
||||
if let Some(id) = foo {
|
||||
glib::source::source_remove(id);
|
||||
}
|
||||
|
||||
undo_callback();
|
||||
|
||||
// Hide the notification
|
||||
revealer.set_reveal_child(false);
|
||||
});
|
||||
|
||||
// Hide the revealer when the close button is clicked
|
||||
let revealer = notif.revealer.clone();
|
||||
notif.close.connect_clicked(move |_| {
|
||||
revealer.set_reveal_child(false);
|
||||
});
|
||||
|
||||
match show_undo {
|
||||
UndoState::Shown => (),
|
||||
UndoState::Hidden => notif.undo.hide(),
|
||||
}
|
||||
|
||||
notif
|
||||
}
|
||||
|
||||
// This is a seperate method cause in order to get a nice animation
|
||||
// the revealer should be attached to something that displays it.
|
||||
// Previously we where doing it in the constructor, which had the result
|
||||
// of the animation being skipped cause there was no parent widget to display it.
|
||||
pub fn show(&self, overlay: >k::Overlay) {
|
||||
overlay.add_overlay(&self.revealer);
|
||||
// We need to display the notification after the widget is added to the overlay
|
||||
// so there will be a nice animation.
|
||||
self.revealer.set_reveal_child(true);
|
||||
}
|
||||
}
|
||||
@ -1,326 +0,0 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use failure::ResultExt;
|
||||
use rayon;
|
||||
use url::Url;
|
||||
|
||||
use hammond_data::{dbqueries, opml, Source};
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use app::Action;
|
||||
use stacks::Content;
|
||||
use utils::{self, itunes_to_rss, refresh};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
// TODO: split this into smaller
|
||||
pub struct Header {
|
||||
container: gtk::HeaderBar,
|
||||
add_toggle: gtk::MenuButton,
|
||||
switch: gtk::StackSwitcher,
|
||||
back: gtk::Button,
|
||||
show_title: gtk::Label,
|
||||
about: gtk::ModelButton,
|
||||
import: gtk::ModelButton,
|
||||
export: gtk::ModelButton,
|
||||
update_button: gtk::ModelButton,
|
||||
update_box: gtk::Box,
|
||||
update_label: gtk::Label,
|
||||
update_spinner: gtk::Spinner,
|
||||
}
|
||||
|
||||
impl Default for Header {
|
||||
fn default() -> Header {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
|
||||
|
||||
let header = builder.get_object("headerbar").unwrap();
|
||||
let add_toggle = builder.get_object("add_toggle").unwrap();
|
||||
let switch = builder.get_object("switch").unwrap();
|
||||
let back = builder.get_object("back").unwrap();
|
||||
let show_title = builder.get_object("show_title").unwrap();
|
||||
let import = builder.get_object("import").unwrap();
|
||||
let export = builder.get_object("export").unwrap();
|
||||
let update_button = builder.get_object("update_button").unwrap();
|
||||
let update_box = builder.get_object("update_notification").unwrap();
|
||||
let update_label = builder.get_object("update_label").unwrap();
|
||||
let update_spinner = builder.get_object("update_spinner").unwrap();
|
||||
let about = builder.get_object("about").unwrap();
|
||||
|
||||
Header {
|
||||
container: header,
|
||||
add_toggle,
|
||||
switch,
|
||||
back,
|
||||
show_title,
|
||||
about,
|
||||
import,
|
||||
export,
|
||||
update_button,
|
||||
update_box,
|
||||
update_label,
|
||||
update_spinner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor components into smaller state machines
|
||||
impl Header {
|
||||
pub fn new(content: &Content, window: >k::Window, sender: &Sender<Action>) -> Header {
|
||||
let h = Header::default();
|
||||
h.init(content, window, &sender);
|
||||
h
|
||||
}
|
||||
|
||||
pub fn init(&self, content: &Content, window: >k::Window, sender: &Sender<Action>) {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
|
||||
|
||||
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
|
||||
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
|
||||
let add_button: gtk::Button = builder.get_object("add_button").unwrap();
|
||||
let result_label: gtk::Label = builder.get_object("result_label").unwrap();
|
||||
self.switch.set_stack(&content.get_stack());
|
||||
|
||||
new_url.connect_changed(clone!(add_button => move |url| {
|
||||
on_url_change(url, &result_label, &add_button)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
}));
|
||||
|
||||
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
|
||||
on_add_bttn_clicked(&new_url, sender.clone())
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
add_popover.hide();
|
||||
}));
|
||||
|
||||
self.add_toggle.set_popover(&add_popover);
|
||||
|
||||
self.update_button
|
||||
.connect_clicked(clone!(sender => move |_| {
|
||||
gtk::idle_add(clone!(sender => move || {
|
||||
let s: Option<Vec<_>> = None;
|
||||
refresh(s, sender.clone());
|
||||
glib::Continue(false)
|
||||
}));
|
||||
}));
|
||||
|
||||
self.about
|
||||
.connect_clicked(clone!(window => move |_| about_dialog(&window)));
|
||||
|
||||
self.import.connect_clicked(
|
||||
clone!(window, sender => move |_| on_import_clicked(&window, &sender)),
|
||||
);
|
||||
|
||||
// Add the Headerbar to the window.
|
||||
window.set_titlebar(&self.container);
|
||||
|
||||
let switch = &self.switch;
|
||||
let add_toggle = &self.add_toggle;
|
||||
let show_title = &self.show_title;
|
||||
self.back.connect_clicked(
|
||||
clone!(switch, add_toggle, show_title, sender => move |back| {
|
||||
switch.show();
|
||||
add_toggle.show();
|
||||
back.hide();
|
||||
show_title.hide();
|
||||
sender.send(Action::ShowShowsAnimated)
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn switch_to_back(&self, title: &str) {
|
||||
self.switch.hide();
|
||||
self.add_toggle.hide();
|
||||
self.back.show();
|
||||
self.set_show_title(title);
|
||||
self.show_title.show();
|
||||
}
|
||||
|
||||
pub fn switch_to_normal(&self) {
|
||||
self.switch.show();
|
||||
self.add_toggle.show();
|
||||
self.back.hide();
|
||||
self.show_title.hide();
|
||||
}
|
||||
|
||||
pub fn set_show_title(&self, title: &str) {
|
||||
self.show_title.set_text(title)
|
||||
}
|
||||
|
||||
pub fn show_update_notification(&self) {
|
||||
self.update_spinner.start();
|
||||
self.update_box.show();
|
||||
self.update_spinner.show();
|
||||
self.update_label.show();
|
||||
}
|
||||
|
||||
pub fn hide_update_notification(&self) {
|
||||
self.update_spinner.stop();
|
||||
self.update_box.hide();
|
||||
self.update_spinner.hide();
|
||||
self.update_label.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: THIS ALSO SUCKS!
|
||||
fn on_add_bttn_clicked(entry: >k::Entry, sender: Sender<Action>) -> Result<(), Error> {
|
||||
let url = entry.get_text().unwrap_or_default();
|
||||
let url = if url.contains("itunes.com") || url.contains("apple.com") {
|
||||
info!("Detected itunes url.");
|
||||
let foo = itunes_to_rss(&url)?;
|
||||
info!("Resolved to {}", foo);
|
||||
foo
|
||||
} else {
|
||||
url.to_owned()
|
||||
};
|
||||
|
||||
let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?;
|
||||
entry.set_text("");
|
||||
|
||||
gtk::idle_add(move || {
|
||||
refresh(Some(vec![source.clone()]), sender.clone());
|
||||
glib::Continue(false)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// FIXME: THIS SUCKS!
|
||||
fn on_url_change(
|
||||
entry: >k::Entry,
|
||||
result: >k::Label,
|
||||
add_button: >k::Button,
|
||||
) -> Result<(), Error> {
|
||||
let uri = entry
|
||||
.get_text()
|
||||
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
|
||||
debug!("Url: {}", uri);
|
||||
|
||||
let url = Url::parse(&uri);
|
||||
// TODO: refactor to avoid duplication
|
||||
match url {
|
||||
Ok(u) => {
|
||||
if !dbqueries::source_exists(u.as_str())? {
|
||||
add_button.set_sensitive(true);
|
||||
result.hide();
|
||||
result.set_label("");
|
||||
} else {
|
||||
add_button.set_sensitive(false);
|
||||
result.set_label("Show already exists.");
|
||||
result.show();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
add_button.set_sensitive(false);
|
||||
if !uri.is_empty() {
|
||||
result.set_label("Invalid url.");
|
||||
result.show();
|
||||
error!("Error: {}", err);
|
||||
} else {
|
||||
result.hide();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_import_clicked(window: >k::Window, sender: &Sender<Action>) {
|
||||
use glib::translate::ToGlib;
|
||||
use gtk::{FileChooserAction, FileChooserDialog, FileFilter, ResponseType};
|
||||
|
||||
// let dialog = FileChooserDialog::new(title, Some(&window), FileChooserAction::Open);
|
||||
// TODO: It might be better to use a FileChooserNative widget.
|
||||
// Create the FileChooser Dialog
|
||||
let dialog = FileChooserDialog::with_buttons(
|
||||
Some("Select the file from which to you want to Import Shows."),
|
||||
Some(window),
|
||||
FileChooserAction::Open,
|
||||
&[
|
||||
("_Cancel", ResponseType::Cancel),
|
||||
("_Open", ResponseType::Accept),
|
||||
],
|
||||
);
|
||||
|
||||
// Do not show hidden(.thing) files
|
||||
dialog.set_show_hidden(false);
|
||||
|
||||
// Set a filter to show only xml files
|
||||
let filter = FileFilter::new();
|
||||
FileFilterExt::set_name(&filter, Some("OPML file"));
|
||||
filter.add_mime_type("application/xml");
|
||||
filter.add_mime_type("text/xml");
|
||||
dialog.add_filter(&filter);
|
||||
|
||||
dialog.connect_response(clone!(sender => move |dialog, resp| {
|
||||
debug!("Dialong Response {}", resp);
|
||||
if resp == ResponseType::Accept.to_glib() {
|
||||
// TODO: Show an in-app notifictaion if the file can not be accessed
|
||||
if let Some(filename) = dialog.get_filename() {
|
||||
debug!("File selected: {:?}", filename);
|
||||
|
||||
rayon::spawn(clone!(sender => move || {
|
||||
// Parse the file and import the feeds
|
||||
if let Ok(sources) = opml::import_from_file(filename) {
|
||||
// Refresh the succesfully parsed feeds to index them
|
||||
utils::refresh(Some(sources), sender)
|
||||
} else {
|
||||
let text = String::from("Failed to parse the Imported file");
|
||||
sender.send(Action::ErrorNotification(text))
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
let text = String::from("Selected File could not be accessed.");
|
||||
sender.send(Action::ErrorNotification(text))
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
dialog.destroy();
|
||||
}));
|
||||
|
||||
dialog.run();
|
||||
}
|
||||
|
||||
// Totally copied it from fractal.
|
||||
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
|
||||
fn about_dialog(window: >k::Window) {
|
||||
// Feel free to add yourself if you contribured.
|
||||
let authors = &[
|
||||
"Constantin Nickel",
|
||||
"Gabriele Musco",
|
||||
"James Wykeham-Martin",
|
||||
"Jordan Petridis",
|
||||
"Julian Sparber",
|
||||
"Rowan Lewis",
|
||||
];
|
||||
|
||||
let dialog = gtk::AboutDialog::new();
|
||||
// Waiting for a logo.
|
||||
// dialog.set_logo_icon_name("org.gnome.Hammond");
|
||||
dialog.set_logo_icon_name("multimedia-player");
|
||||
dialog.set_comments("Podcast Client for the GNOME Desktop.");
|
||||
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
|
||||
dialog.set_license_type(gtk::License::Gpl30);
|
||||
dialog.set_modal(true);
|
||||
// TODO: make it show it fetches the commit hash from which it was built
|
||||
// and the version number is kept in sync automaticly
|
||||
dialog.set_version("0.3.3");
|
||||
dialog.set_program_name("Hammond");
|
||||
// TODO: Need a wiki page first.
|
||||
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
|
||||
// dialog.set_website_label("Learn more about Hammond");
|
||||
dialog.set_transient_for(window);
|
||||
|
||||
dialog.set_artists(&["Tobias Bernard"]);
|
||||
dialog.set_authors(authors);
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
#![cfg_attr(
|
||||
feature = "cargo-clippy",
|
||||
allow(clone_on_ref_ptr, blacklisted_name, match_same_arms, option_map_unit_fn)
|
||||
)]
|
||||
#![allow(unknown_lints)]
|
||||
#![warn(unused_extern_crates, unused)]
|
||||
#![deny(warnings)]
|
||||
|
||||
extern crate gdk;
|
||||
extern crate gdk_pixbuf;
|
||||
extern crate gio;
|
||||
extern crate glib;
|
||||
extern crate gtk;
|
||||
|
||||
#[macro_use]
|
||||
extern crate failure;
|
||||
// #[macro_use]
|
||||
// extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
extern crate chrono;
|
||||
extern crate crossbeam_channel;
|
||||
extern crate hammond_data;
|
||||
extern crate hammond_downloader;
|
||||
extern crate html2pango;
|
||||
extern crate humansize;
|
||||
extern crate loggerv;
|
||||
extern crate open;
|
||||
extern crate rayon;
|
||||
extern crate regex;
|
||||
extern crate reqwest;
|
||||
extern crate send_cell;
|
||||
extern crate serde_json;
|
||||
extern crate take_mut;
|
||||
extern crate url;
|
||||
|
||||
use log::Level;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
// http://gtk-rs.org/tuto/closures
|
||||
#[macro_export]
|
||||
macro_rules! clone {
|
||||
(@param _) => ( _ );
|
||||
(@param $x:ident) => ( $x );
|
||||
($($n:ident),+ => move || $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move || $body
|
||||
}
|
||||
);
|
||||
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move |$(clone!(@param $p),)+| $body
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
mod stacks;
|
||||
mod widgets;
|
||||
|
||||
mod app;
|
||||
mod headerbar;
|
||||
|
||||
mod appnotif;
|
||||
mod manager;
|
||||
mod settings;
|
||||
mod static_resource;
|
||||
mod utils;
|
||||
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
// TODO: make the the logger a cli -vv option
|
||||
loggerv::init_with_level(Level::Info).expect("Error initializing loggerv.");
|
||||
gtk::init().expect("Error initializing gtk.");
|
||||
static_resource::init().expect("Something went wrong with the resource file initialization.");
|
||||
|
||||
// Add custom style
|
||||
let provider = gtk::CssProvider::new();
|
||||
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
|
||||
gtk::StyleContext::add_provider_for_screen(
|
||||
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
|
||||
&provider,
|
||||
600,
|
||||
);
|
||||
|
||||
App::new().run();
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
|
||||
use app::Action;
|
||||
use stacks::{HomeStack, ShowStack};
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Content {
|
||||
stack: gtk::Stack,
|
||||
shows: Rc<RefCell<ShowStack>>,
|
||||
home: Rc<RefCell<HomeStack>>,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
|
||||
let stack = gtk::Stack::new();
|
||||
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
|
||||
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?));
|
||||
|
||||
stack.add_titled(&home.borrow().get_stack(), "home", "Recent");
|
||||
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
|
||||
|
||||
Ok(Content {
|
||||
stack,
|
||||
shows,
|
||||
home,
|
||||
sender,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&self) {
|
||||
self.update_home();
|
||||
self.update_shows();
|
||||
}
|
||||
|
||||
pub fn update_home(&self) {
|
||||
self.home
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update HomeView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_home_if_background(&self) {
|
||||
if self.stack.get_visible_child_name() != Some("home".into()) {
|
||||
self.update_home();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_shows(&self) {
|
||||
self.shows
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_shows_view(&self) {
|
||||
self.shows
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_widget_if_same(&self, pid: i32) {
|
||||
let pop = self.shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.update_widget_if_same(pid)
|
||||
.map_err(|err| error!("Failed to update ShowsWidget: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
|
||||
self.shows.clone()
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::StackTransitionType;
|
||||
|
||||
use failure::Error;
|
||||
use hammond_data::dbqueries::is_episodes_populated;
|
||||
use hammond_data::errors::DataError;
|
||||
|
||||
use app::Action;
|
||||
use widgets::{EmptyView, HomeView};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum State {
|
||||
Home,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomeStack {
|
||||
empty: EmptyView,
|
||||
episodes: Rc<HomeView>,
|
||||
stack: gtk::Stack,
|
||||
state: State,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl HomeStack {
|
||||
pub fn new(sender: Sender<Action>) -> Result<HomeStack, Error> {
|
||||
let episodes = HomeView::new(sender.clone())?;
|
||||
let empty = EmptyView::new();
|
||||
let stack = gtk::Stack::new();
|
||||
let state = State::Empty;
|
||||
|
||||
stack.add_named(&episodes.container, "home");
|
||||
stack.add_named(&empty.container, "empty");
|
||||
|
||||
let mut home = HomeStack {
|
||||
empty,
|
||||
episodes,
|
||||
stack,
|
||||
state,
|
||||
sender,
|
||||
};
|
||||
|
||||
home.determine_state()?;
|
||||
Ok(home)
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn update(&mut self) -> Result<(), Error> {
|
||||
// Copy the vertical scrollbar adjustment from the old view.
|
||||
self.episodes
|
||||
.save_alignment()
|
||||
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
|
||||
.ok();
|
||||
|
||||
self.replace_view()?;
|
||||
// Determine the actuall state.
|
||||
self.determine_state().map_err(From::from)
|
||||
}
|
||||
|
||||
fn replace_view(&mut self) -> Result<(), Error> {
|
||||
// Get the container of the view
|
||||
let old = &self.episodes.container.clone();
|
||||
let eps = HomeView::new(self.sender.clone())?;
|
||||
|
||||
// Remove the old widget and add the new one
|
||||
// during this the previous view is removed,
|
||||
// and the visibile child fallsback to empty view.
|
||||
self.stack.remove(old);
|
||||
self.stack.add_named(&eps.container, "home");
|
||||
// Keep the previous state.
|
||||
let s = self.state;
|
||||
// Set the visible child back to the previous one to avoid
|
||||
// the stack transition animation to show the empty view
|
||||
self.switch_visible(s, StackTransitionType::None);
|
||||
|
||||
// replace view in the struct too
|
||||
self.episodes = eps;
|
||||
|
||||
// This might not be needed
|
||||
old.destroy();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn switch_visible(&mut self, s: State, animation: StackTransitionType) {
|
||||
use self::State::*;
|
||||
|
||||
match s {
|
||||
Home => {
|
||||
self.stack.set_visible_child_full("home", animation);
|
||||
self.state = Home;
|
||||
}
|
||||
Empty => {
|
||||
self.stack.set_visible_child_full("empty", animation);
|
||||
self.state = Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_state(&mut self) -> Result<(), DataError> {
|
||||
if is_episodes_populated()? {
|
||||
self.switch_visible(State::Home, StackTransitionType::Crossfade);
|
||||
} else {
|
||||
self.switch_visible(State::Empty, StackTransitionType::Crossfade);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
mod content;
|
||||
mod home;
|
||||
mod populated;
|
||||
mod show;
|
||||
|
||||
pub use self::content::Content;
|
||||
pub use self::home::HomeStack;
|
||||
pub use self::populated::{PopulatedStack, PopulatedState};
|
||||
pub use self::show::{ShowStack, ShowState};
|
||||
@ -1,162 +0,0 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::StackTransitionType;
|
||||
|
||||
use failure::Error;
|
||||
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::Podcast;
|
||||
|
||||
use app::Action;
|
||||
use widgets::{ShowWidget, ShowsView};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PopulatedState {
|
||||
View,
|
||||
Widget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PopulatedStack {
|
||||
container: gtk::Box,
|
||||
populated: Rc<ShowsView>,
|
||||
show: Rc<ShowWidget>,
|
||||
stack: gtk::Stack,
|
||||
state: PopulatedState,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl PopulatedStack {
|
||||
pub fn new(sender: Sender<Action>) -> Result<PopulatedStack, Error> {
|
||||
let stack = gtk::Stack::new();
|
||||
let state = PopulatedState::View;
|
||||
let populated = ShowsView::new(sender.clone())?;
|
||||
let show = Rc::new(ShowWidget::default());
|
||||
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
|
||||
stack.add_named(&populated.container, "shows");
|
||||
stack.add_named(&show.container, "widget");
|
||||
container.add(&stack);
|
||||
container.show_all();
|
||||
|
||||
let show = PopulatedStack {
|
||||
container,
|
||||
stack,
|
||||
populated,
|
||||
show,
|
||||
state,
|
||||
sender,
|
||||
};
|
||||
|
||||
Ok(show)
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
self.update_widget().map_err(|err| format!("{}", err)).ok();
|
||||
self.update_shows().map_err(|err| format!("{}", err)).ok();
|
||||
}
|
||||
|
||||
pub fn update_shows(&mut self) -> Result<(), Error> {
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.replace_shows()?;
|
||||
self.switch_visible(s, StackTransitionType::Crossfade);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_shows(&mut self) -> Result<(), Error> {
|
||||
let old = &self.populated.container.clone();
|
||||
debug!("Name: {:?}", WidgetExt::get_name(old));
|
||||
|
||||
self.populated
|
||||
.save_alignment()
|
||||
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
|
||||
.ok();
|
||||
|
||||
let pop = ShowsView::new(self.sender.clone())?;
|
||||
self.populated = pop;
|
||||
self.stack.remove(old);
|
||||
self.stack.add_named(&self.populated.container, "shows");
|
||||
|
||||
old.destroy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_widget(&mut self, pd: Arc<Podcast>) -> Result<(), Error> {
|
||||
let old = self.show.container.clone();
|
||||
|
||||
// save the ShowWidget vertical scrollabar alignment
|
||||
self.show
|
||||
.podcast_id()
|
||||
.map(|id| self.show.save_vadjustment(id));
|
||||
|
||||
let new = ShowWidget::new(pd, self.sender.clone());
|
||||
self.show = new;
|
||||
self.stack.remove(&old);
|
||||
self.stack.add_named(&self.show.container, "widget");
|
||||
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.switch_visible(s, StackTransitionType::None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_widget(&mut self) -> Result<(), Error> {
|
||||
let old = self.show.container.clone();
|
||||
let id = self.show.podcast_id();
|
||||
if id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pd = dbqueries::get_podcast_from_id(id.unwrap_or_default())?;
|
||||
self.replace_widget(Arc::new(pd))?;
|
||||
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.switch_visible(s, StackTransitionType::Crossfade);
|
||||
|
||||
old.destroy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Only update widget if its podcast_id is equal to pid.
|
||||
pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
|
||||
if self.show.podcast_id() != Some(pid) {
|
||||
debug!("Different widget. Early return");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.update_widget()
|
||||
}
|
||||
|
||||
pub fn container(&self) -> gtk::Box {
|
||||
self.container.clone()
|
||||
}
|
||||
|
||||
pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
|
||||
use self::PopulatedState::*;
|
||||
|
||||
match state {
|
||||
View => {
|
||||
self.stack.set_visible_child_full("shows", animation);
|
||||
self.state = View;
|
||||
}
|
||||
Widget => {
|
||||
self.stack.set_visible_child_full("widget", animation);
|
||||
self.state = Widget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use hammond_data::dbqueries::is_podcasts_populated;
|
||||
|
||||
use app::Action;
|
||||
use stacks::PopulatedStack;
|
||||
use utils::get_ignored_shows;
|
||||
use widgets::EmptyView;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ShowState {
|
||||
Populated,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowStack {
|
||||
empty: EmptyView,
|
||||
populated: Rc<RefCell<PopulatedStack>>,
|
||||
stack: gtk::Stack,
|
||||
state: ShowState,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl ShowStack {
|
||||
pub fn new(sender: Sender<Action>) -> Result<Self, Error> {
|
||||
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())?));
|
||||
let empty = EmptyView::new();
|
||||
let stack = gtk::Stack::new();
|
||||
let state = ShowState::Empty;
|
||||
|
||||
stack.add_named(&populated.borrow().container(), "populated");
|
||||
stack.add_named(&empty.container, "empty");
|
||||
|
||||
let mut show = ShowStack {
|
||||
empty,
|
||||
populated,
|
||||
stack,
|
||||
state,
|
||||
sender,
|
||||
};
|
||||
|
||||
show.determine_state()?;
|
||||
Ok(show)
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
|
||||
self.populated.clone()
|
||||
}
|
||||
|
||||
pub fn update(&mut self) -> Result<(), Error> {
|
||||
self.populated.borrow_mut().update();
|
||||
self.determine_state()
|
||||
}
|
||||
|
||||
fn switch_visible(&mut self, s: ShowState) {
|
||||
use self::ShowState::*;
|
||||
|
||||
match s {
|
||||
Populated => {
|
||||
self.stack.set_visible_child_name("populated");
|
||||
self.state = Populated;
|
||||
}
|
||||
Empty => {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
self.state = Empty;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn determine_state(&mut self) -> Result<(), Error> {
|
||||
use self::ShowState::*;
|
||||
|
||||
let ign = get_ignored_shows()?;
|
||||
debug!("IGNORED SHOWS {:?}", ign);
|
||||
if is_podcasts_populated(&ign)? {
|
||||
self.switch_visible(Populated);
|
||||
} else {
|
||||
self.switch_visible(Empty);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
use gio::{resources_register, Error, Resource};
|
||||
use glib::Bytes;
|
||||
|
||||
pub fn init() -> Result<(), Error> {
|
||||
// load the gresource binary at build time and include/link it into the final
|
||||
// binary.
|
||||
let res_bytes = include_bytes!("../resources/resources.gresource");
|
||||
|
||||
// Create Resource it will live as long the value lives.
|
||||
let gbytes = Bytes::from_static(res_bytes.as_ref());
|
||||
let resource = Resource::new_from_data(&gbytes)?;
|
||||
|
||||
// Register the resource so It wont be dropped and will continue to live in
|
||||
// memory.
|
||||
resources_register(&resource);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -1,388 +0,0 @@
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
||||
|
||||
use gdk::FrameClockExt;
|
||||
use gdk_pixbuf::Pixbuf;
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IsA, Widget};
|
||||
|
||||
use chrono::prelude::*;
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
use regex::Regex;
|
||||
use reqwest;
|
||||
use send_cell::SendCell;
|
||||
use serde_json::Value;
|
||||
|
||||
// use hammond_data::feed;
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::pipeline;
|
||||
use hammond_data::utils::checkup;
|
||||
use hammond_data::Source;
|
||||
use hammond_downloader::downloader;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
use app::Action;
|
||||
|
||||
/// Lazy evaluates and loads widgets to the parent `container` widget.
|
||||
///
|
||||
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
|
||||
/// will be constructed. An `FnMut` function that returns the desired
|
||||
/// widget should be passed as the widget `constructor`. You can also specify
|
||||
/// a `callback` that will be executed when the iteration finish.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # struct Message;
|
||||
/// # struct MessageWidget(gtk::Label);
|
||||
///
|
||||
/// # impl MessageWidget {
|
||||
/// # fn new(_: Message) -> Self {
|
||||
/// # MessageWidget(gtk::Label::new("A message"))
|
||||
/// # }
|
||||
/// # }
|
||||
///
|
||||
/// let messages: Vec<Message> = Vec::new();
|
||||
/// let list = gtk::ListBox::new();
|
||||
/// let constructor = |m| { MessageWidget::new(m).0};
|
||||
/// lazy_load(messages, list, constructor, || {});
|
||||
/// ```
|
||||
///
|
||||
/// If you have already constructed the widgets and only want to
|
||||
/// load them to the parent you can pass a closure that returns it's
|
||||
/// own argument to the constructor.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::collections::binary_heap::BinaryHeap;
|
||||
/// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
|
||||
/// let list = gtk::ListBox::new();
|
||||
/// lazy_load(widgets, list, |w| w, || {});
|
||||
/// ```
|
||||
pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
|
||||
where
|
||||
T: IntoIterator + 'static,
|
||||
T::Item: 'static,
|
||||
C: ContainerExt + 'static,
|
||||
F: FnMut(T::Item) -> W + 'static,
|
||||
W: IsA<Widget> + WidgetExt,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let func = move |x| {
|
||||
let widget = contructor(x);
|
||||
container.add(&widget);
|
||||
widget.show();
|
||||
};
|
||||
lazy_load_full(data, func, callback);
|
||||
}
|
||||
|
||||
/// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
|
||||
/// when the iteration finishes, it executes `finish_callback`.
|
||||
///
|
||||
/// This is a more flexible version of `lazy_load` with less constrains.
|
||||
/// If you just want to lazy add `widgets` to a `container` check if
|
||||
/// `lazy_load` fits your needs first.
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
|
||||
pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
|
||||
where
|
||||
T: IntoIterator + 'static,
|
||||
T::Item: 'static,
|
||||
F: FnMut(T::Item) + 'static,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let mut data = data.into_iter();
|
||||
gtk::idle_add(move || {
|
||||
data.next()
|
||||
.map(|x| func(x))
|
||||
.map(|_| glib::Continue(true))
|
||||
.unwrap_or_else(|| {
|
||||
finish_callback();
|
||||
glib::Continue(false)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Kudos to Julian Sparber
|
||||
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
|
||||
pub fn smooth_scroll_to(view: >k::ScrolledWindow, target: >k::Adjustment) {
|
||||
if let Some(adj) = view.get_vadjustment() {
|
||||
if let Some(clock) = view.get_frame_clock() {
|
||||
let duration = 200;
|
||||
let start = adj.get_value();
|
||||
let end = target.get_value();
|
||||
let start_time = clock.get_frame_time();
|
||||
let end_time = start_time + 1000 * duration;
|
||||
|
||||
view.add_tick_callback(move |_, clock| {
|
||||
let now = clock.get_frame_time();
|
||||
// FIXME: `adj.get_value != end` is a float comparison...
|
||||
if now < end_time && adj.get_value().abs() != end.abs() {
|
||||
let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
|
||||
t = ease_out_cubic(t);
|
||||
adj.set_value(start + t * (end - start));
|
||||
glib::Continue(true)
|
||||
} else {
|
||||
adj.set_value(end);
|
||||
glib::Continue(false)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From clutter-easing.c, based on Robert Penner's
|
||||
// infamous easing equations, MIT license.
|
||||
fn ease_out_cubic(t: f64) -> f64 {
|
||||
let p = t - 1f64;
|
||||
p * p * p + 1f64
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
}
|
||||
|
||||
pub fn ignore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.insert(id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn uningore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.remove(&id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn cleanup(cleanup_date: DateTime<Utc>) {
|
||||
checkup(cleanup_date)
|
||||
.map_err(|err| error!("Check up failed: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn refresh<S>(source: Option<S>, sender: Sender<Action>)
|
||||
where
|
||||
S: IntoIterator<Item = Source> + Send + 'static,
|
||||
{
|
||||
refresh_feed(source, sender)
|
||||
.map_err(|err| error!("Failed to update feeds: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Update the rss feed(s) originating from `source`.
|
||||
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
|
||||
/// When It's done,it queues up a `RefreshViews` action.
|
||||
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
|
||||
where
|
||||
S: IntoIterator<Item = Source> + Send + 'static,
|
||||
{
|
||||
sender
|
||||
.send(Action::HeaderBarShowUpdateIndicator)
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
|
||||
rayon::spawn(move || {
|
||||
if let Some(s) = source {
|
||||
// Refresh only specified feeds
|
||||
pipeline::run(s, false)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Error While trying to update the database."))
|
||||
.ok();
|
||||
} else {
|
||||
// Refresh all the feeds
|
||||
dbqueries::get_sources()
|
||||
.map(|s| s.into_iter())
|
||||
.and_then(|s| pipeline::run(s, false))
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
};
|
||||
|
||||
sender
|
||||
.send(Action::HeaderBarHideUpdateIndicator)
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
sender
|
||||
.send(Action::RefreshAllViews)
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
|
||||
{ RwLock::new(HashMap::new()) };
|
||||
static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
|
||||
static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||
}
|
||||
|
||||
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
|
||||
// use the cover of the Podcast Feed/Show, We can only create a Pixbuf
|
||||
// cover per show and pass around the Rc pointer.
|
||||
//
|
||||
// GObjects do not implement Send trait, so SendCell is a way around that.
|
||||
// Also lazy_static requires Sync trait, so that's what the mutexes are.
|
||||
// TODO: maybe use something that would just scale to requested size?
|
||||
pub fn set_image_from_path(image: >k::Image, podcast_id: i32, size: u32) -> Result<(), Error> {
|
||||
// Check if there's an active download about this show cover.
|
||||
// If there is, a callback will be set so this function will be called again.
|
||||
// If the download succedes, there should be a quick return from the pixbuf cache_image
|
||||
// If it fails another download will be scheduled.
|
||||
if let Ok(guard) = COVER_DL_REGISTRY.read() {
|
||||
if guard.contains(&podcast_id) {
|
||||
let callback = clone!(image => move || {
|
||||
let _ = set_image_from_path(&image, podcast_id, size);
|
||||
glib::Continue(false)
|
||||
});
|
||||
gtk::timeout_add(250, callback);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
|
||||
// Check if the requested (cover + size) is already in the chache
|
||||
// and if so do an early return after that.
|
||||
if let Some(guard) = hashmap.get(&(podcast_id, size)) {
|
||||
guard
|
||||
.lock()
|
||||
.map_err(|err| format_err!("SendCell Mutex: {}", err))
|
||||
.and_then(|sendcell| {
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|px| image.set_from_pixbuf(px))
|
||||
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
|
||||
})?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (sender, receiver) = channel();
|
||||
THREADPOOL.spawn(move || {
|
||||
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
|
||||
guard.insert(podcast_id);
|
||||
}
|
||||
|
||||
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(podcast_id) {
|
||||
sender
|
||||
.send(downloader::cache_image(&pd))
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
|
||||
guard.remove(&podcast_id);
|
||||
}
|
||||
});
|
||||
|
||||
let image = image.clone();
|
||||
let s = size as i32;
|
||||
gtk::timeout_add(25, move || {
|
||||
if let Ok(path) = receiver.try_recv() {
|
||||
if let Ok(path) = path {
|
||||
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
|
||||
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
|
||||
hashmap.insert((podcast_id, size), Mutex::new(SendCell::new(px.clone())));
|
||||
image.set_from_pixbuf(&px);
|
||||
}
|
||||
}
|
||||
}
|
||||
glib::Continue(false)
|
||||
} else {
|
||||
glib::Continue(true)
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>`
|
||||
pub fn itunes_to_rss(url: &str) -> Result<String, Error> {
|
||||
let id = itunes_id_from_url(url).ok_or_else(|| format_err!("Failed to find an Itunes ID."))?;
|
||||
lookup_id(id)
|
||||
}
|
||||
|
||||
fn itunes_id_from_url(url: &str) -> Option<u32> {
|
||||
lazy_static! {
|
||||
static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
|
||||
}
|
||||
|
||||
// Get the itunes id from the url
|
||||
let foo = RE.captures_iter(url).nth(0)?.get(1)?.as_str();
|
||||
// Parse it to a u32, this *should* never fail
|
||||
foo.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn lookup_id(id: u32) -> Result<String, Error> {
|
||||
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
|
||||
let req: Value = reqwest::get(&url)?.json()?;
|
||||
let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
|
||||
rssurl()
|
||||
.map(From::from)
|
||||
.ok_or_else(|| format_err!("Failed to get url from itunes response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// use hammond_data::Source;
|
||||
// use hammond_data::dbqueries;
|
||||
|
||||
// #[test]
|
||||
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
||||
// to run it.
|
||||
// #[ignore]
|
||||
// Disabled till https://gitlab.gnome.org/World/hammond/issues/56
|
||||
// fn test_set_image_from_path() {
|
||||
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
// Create and index a source
|
||||
// let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
// let sid = source.id();
|
||||
// pipeline::run(vec![source], true).unwrap();
|
||||
|
||||
// Get the Podcast
|
||||
// let img = gtk::Image::new();
|
||||
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
|
||||
// let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
|
||||
// assert!(pxbuf.is_ok());
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_itunes_to_rss() {
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
|
||||
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
|
||||
assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
|
||||
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
|
||||
assert!(itunes_to_rss(itunes_url).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_itunes_id() {
|
||||
let id = 1195206601;
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
|
||||
assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_itunes_lookup_id() {
|
||||
let id = 1195206601;
|
||||
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
|
||||
assert_eq!(rss_url, lookup_id(id).unwrap());
|
||||
|
||||
let id = 000000000;
|
||||
assert!(lookup_id(id).is_err());
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
use gtk;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmptyView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl Default for EmptyView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
|
||||
let view: gtk::Box = builder.get_object("empty_view").unwrap();
|
||||
|
||||
EmptyView { container: view }
|
||||
}
|
||||
}
|
||||
|
||||
impl EmptyView {
|
||||
pub fn new() -> EmptyView {
|
||||
EmptyView::default()
|
||||
}
|
||||
}
|
||||
@ -1,384 +0,0 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use humansize::FileSize;
|
||||
use open;
|
||||
use take_mut;
|
||||
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::utils::get_download_folder;
|
||||
use hammond_data::EpisodeWidgetQuery;
|
||||
|
||||
use app::Action;
|
||||
use manager;
|
||||
use widgets::episode_states::*;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ops::DerefMut;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EpisodeWidget {
|
||||
pub container: gtk::Box,
|
||||
date: DateMachine,
|
||||
duration: DurationMachine,
|
||||
title: Rc<RefCell<TitleMachine>>,
|
||||
media: Rc<RefCell<MediaMachine>>,
|
||||
}
|
||||
|
||||
impl Default for EpisodeWidget {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
|
||||
|
||||
let container: gtk::Box = builder.get_object("episode_container").unwrap();
|
||||
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
|
||||
|
||||
let download: gtk::Button = builder.get_object("download_button").unwrap();
|
||||
let play: gtk::Button = builder.get_object("play_button").unwrap();
|
||||
let cancel: gtk::Button = builder.get_object("cancel_button").unwrap();
|
||||
|
||||
let title: gtk::Label = builder.get_object("title_label").unwrap();
|
||||
let date: gtk::Label = builder.get_object("date_label").unwrap();
|
||||
let duration: gtk::Label = builder.get_object("duration_label").unwrap();
|
||||
let local_size: gtk::Label = builder.get_object("local_size").unwrap();
|
||||
let total_size: gtk::Label = builder.get_object("total_size").unwrap();
|
||||
|
||||
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
|
||||
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
|
||||
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
|
||||
|
||||
let date_machine = DateMachine::new(date, 0);
|
||||
let dur_machine = DurationMachine::new(duration, separator1, None);
|
||||
let title_machine = Rc::new(RefCell::new(TitleMachine::new(title, false)));
|
||||
let media = MediaMachine::new(
|
||||
play,
|
||||
download,
|
||||
progress,
|
||||
cancel,
|
||||
total_size,
|
||||
local_size,
|
||||
separator2,
|
||||
prog_separator,
|
||||
);
|
||||
let media_machine = Rc::new(RefCell::new(media));
|
||||
|
||||
EpisodeWidget {
|
||||
container,
|
||||
title: title_machine,
|
||||
duration: dur_machine,
|
||||
date: date_machine,
|
||||
media: media_machine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EpisodeWidget {
|
||||
pub fn new(episode: EpisodeWidgetQuery, sender: &Sender<Action>) -> EpisodeWidget {
|
||||
let mut widget = EpisodeWidget::default();
|
||||
widget.init(episode, sender);
|
||||
widget
|
||||
}
|
||||
|
||||
fn init(&mut self, episode: EpisodeWidgetQuery, sender: &Sender<Action>) {
|
||||
// Set the date label.
|
||||
self.set_date(episode.epoch());
|
||||
|
||||
// Set the title label state.
|
||||
self.set_title(&episode);
|
||||
|
||||
// Set the duaration label.
|
||||
self.set_duration(episode.duration());
|
||||
|
||||
// Determine what the state of the media widgets should be.
|
||||
determine_media_state(&self.media, &episode)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Could not determine Media State"))
|
||||
.ok();
|
||||
|
||||
let episode = Arc::new(Mutex::new(episode));
|
||||
self.connect_buttons(&episode, sender);
|
||||
}
|
||||
|
||||
fn connect_buttons(&self, episode: &Arc<Mutex<EpisodeWidgetQuery>>, sender: &Sender<Action>) {
|
||||
let title = self.title.clone();
|
||||
if let Ok(media) = self.media.try_borrow_mut() {
|
||||
media.play_connect_clicked(clone!(episode, sender => move |_| {
|
||||
if let Ok(mut ep) = episode.lock() {
|
||||
on_play_bttn_clicked(&mut ep, &title, &sender)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
let media_machine = self.media.clone();
|
||||
media.download_connect_clicked(clone!(media_machine, episode, sender => move |dl| {
|
||||
// Make the button insensitive so it won't be pressed twice
|
||||
dl.set_sensitive(false);
|
||||
if let Ok(ep) = episode.lock() {
|
||||
on_download_clicked(&ep, &sender)
|
||||
.and_then(|_| {
|
||||
info!("Donwload started succesfully.");
|
||||
determine_media_state(&media_machine, &ep)
|
||||
})
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Could not determine Media State"))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Restore sensitivity after operations above complete
|
||||
dl.set_sensitive(true);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the title state.
|
||||
fn set_title(&mut self, episode: &EpisodeWidgetQuery) {
|
||||
let mut machine = self.title.borrow_mut();
|
||||
machine.set_title(episode.title());
|
||||
take_mut::take(machine.deref_mut(), |title| {
|
||||
title.determine_state(episode.played().is_some())
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the date label depending on the current time.
|
||||
fn set_date(&mut self, epoch: i32) {
|
||||
let machine = &mut self.date;
|
||||
take_mut::take(machine, |date| date.determine_state(i64::from(epoch)));
|
||||
}
|
||||
|
||||
/// Set the duration label.
|
||||
fn set_duration(&mut self, seconds: Option<i32>) {
|
||||
let machine = &mut self.duration;
|
||||
take_mut::take(machine, |duration| duration.determine_state(seconds));
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_media_state(
|
||||
media_machine: &Rc<RefCell<MediaMachine>>,
|
||||
episode: &EpisodeWidgetQuery,
|
||||
) -> Result<(), Error> {
|
||||
let id = episode.rowid();
|
||||
let active_dl = || -> Result<Option<_>, Error> {
|
||||
let m = manager::ACTIVE_DOWNLOADS
|
||||
.read()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||
|
||||
Ok(m.get(&id).cloned())
|
||||
}()?;
|
||||
|
||||
let mut lock = media_machine.try_borrow_mut()?;
|
||||
take_mut::take(lock.deref_mut(), |media| {
|
||||
media.determine_state(
|
||||
episode.length(),
|
||||
active_dl.is_some(),
|
||||
episode.local_uri().is_some(),
|
||||
)
|
||||
});
|
||||
|
||||
// Show or hide the play/delete/download buttons upon widget initialization.
|
||||
if let Some(prog) = active_dl {
|
||||
// set a callback that will update the state when the download finishes
|
||||
let id = episode.rowid();
|
||||
let callback = clone!(media_machine => move || {
|
||||
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() {
|
||||
if !guard.contains_key(&id) {
|
||||
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||
determine_media_state(&media_machine, &ep)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Could not determine Media State"))
|
||||
.ok();
|
||||
|
||||
return glib::Continue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::Continue(true)
|
||||
});
|
||||
gtk::timeout_add(250, callback);
|
||||
|
||||
lock.cancel_connect_clicked(clone!(prog, media_machine => move |_| {
|
||||
if let Ok(mut m) = prog.lock() {
|
||||
m.cancel();
|
||||
}
|
||||
|
||||
if let Ok(mut lock) = media_machine.try_borrow_mut() {
|
||||
if let Ok(episode) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||
take_mut::take(lock.deref_mut(), |media| {
|
||||
media.determine_state(
|
||||
episode.length(),
|
||||
false,
|
||||
episode.local_uri().is_some(),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
drop(lock);
|
||||
|
||||
// Setup a callback that will update the progress bar.
|
||||
update_progressbar_callback(&prog, &media_machine, id);
|
||||
|
||||
// Setup a callback that will update the total_size label
|
||||
// with the http ContentLength header number rather than
|
||||
// relying to the RSS feed.
|
||||
update_total_size_callback(&prog, &media_machine);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
|
||||
let download_fold = get_download_folder(&pd.title())?;
|
||||
|
||||
// Start a new download.
|
||||
manager::add(ep.rowid(), download_fold)?;
|
||||
|
||||
// Update Views
|
||||
sender.send(Action::RefreshEpisodesViewBGR)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_play_bttn_clicked(
|
||||
episode: &mut EpisodeWidgetQuery,
|
||||
title: &Rc<RefCell<TitleMachine>>,
|
||||
sender: &Sender<Action>,
|
||||
) -> Result<(), Error> {
|
||||
open_uri(episode.rowid())?;
|
||||
episode.set_played_now()?;
|
||||
|
||||
let mut machine = title.try_borrow_mut()?;
|
||||
take_mut::take(machine.deref_mut(), |title| {
|
||||
title.determine_state(episode.played().is_some())
|
||||
});
|
||||
|
||||
sender.send(Action::RefreshEpisodesViewBGR)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_uri(rowid: i32) -> Result<(), Error> {
|
||||
let uri = dbqueries::get_episode_local_uri_from_id(rowid)?
|
||||
.ok_or_else(|| format_err!("Expected Some found None."))?;
|
||||
|
||||
if Path::new(&uri).exists() {
|
||||
info!("Opening {}", uri);
|
||||
open::that(&uri)?;
|
||||
} else {
|
||||
bail!("File \"{}\" does not exist.", uri);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Setup a callback that will update the progress bar.
|
||||
#[inline]
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
|
||||
fn update_progressbar_callback(
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
media: &Rc<RefCell<MediaMachine>>,
|
||||
episode_rowid: i32,
|
||||
) {
|
||||
let callback = clone!(prog, media => move || {
|
||||
progress_bar_helper(&prog, &media, episode_rowid)
|
||||
.unwrap_or(glib::Continue(false))
|
||||
});
|
||||
timeout_add(300, callback);
|
||||
}
|
||||
|
||||
#[allow(if_same_then_else)]
|
||||
fn progress_bar_helper(
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
media: &Rc<RefCell<MediaMachine>>,
|
||||
episode_rowid: i32,
|
||||
) -> Result<glib::Continue, Error> {
|
||||
let (fraction, downloaded) = {
|
||||
let m = prog.lock()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||
(m.get_fraction(), m.get_downloaded())
|
||||
};
|
||||
|
||||
// I hate floating points.
|
||||
// Update the progress_bar.
|
||||
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
|
||||
// Update local_size label
|
||||
let size = downloaded
|
||||
.file_size(SIZE_OPTS.clone())
|
||||
.map_err(|err| format_err!("{}", err))?;
|
||||
|
||||
if let Ok(mut m) = media.try_borrow_mut() {
|
||||
m.update_progress(&size, fraction);
|
||||
}
|
||||
}
|
||||
|
||||
// info!("Fraction: {}", progress_bar.get_fraction());
|
||||
// info!("Fraction: {}", fraction);
|
||||
|
||||
// Check if the download is still active
|
||||
let active = {
|
||||
let m = manager::ACTIVE_DOWNLOADS
|
||||
.read()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||
m.contains_key(&episode_rowid)
|
||||
};
|
||||
|
||||
if (fraction >= 1.0) && (!fraction.is_nan()) {
|
||||
Ok(glib::Continue(false))
|
||||
} else if !active {
|
||||
Ok(glib::Continue(false))
|
||||
} else {
|
||||
Ok(glib::Continue(true))
|
||||
}
|
||||
}
|
||||
|
||||
// Setup a callback that will update the total_size label
|
||||
// with the http ContentLength header number rather than
|
||||
// relying to the RSS feed.
|
||||
#[inline]
|
||||
fn update_total_size_callback(
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
media: &Rc<RefCell<MediaMachine>>,
|
||||
) {
|
||||
let callback = clone!(prog, media => move || {
|
||||
total_size_helper(&prog, &media).unwrap_or(glib::Continue(true))
|
||||
});
|
||||
timeout_add(500, callback);
|
||||
}
|
||||
|
||||
fn total_size_helper(
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
media: &Rc<RefCell<MediaMachine>>,
|
||||
) -> Result<glib::Continue, Error> {
|
||||
// Get the total_bytes.
|
||||
let total_bytes = {
|
||||
let m = prog.lock()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||
m.get_total_size()
|
||||
};
|
||||
|
||||
debug!("Total Size: {}", total_bytes);
|
||||
if total_bytes != 0 {
|
||||
// Update the total_size label
|
||||
if let Ok(mut m) = media.try_borrow_mut() {
|
||||
take_mut::take(m.deref_mut(), |machine| {
|
||||
machine.set_size(Some(total_bytes as i32))
|
||||
});
|
||||
}
|
||||
|
||||
// Do not call again the callback
|
||||
Ok(glib::Continue(false))
|
||||
} else {
|
||||
Ok(glib::Continue(true))
|
||||
}
|
||||
}
|
||||
|
||||
// fn on_delete_bttn_clicked(episode_id: i32) -> Result<(), Error> {
|
||||
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
|
||||
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
|
||||
// }
|
||||
@ -1,879 +0,0 @@
|
||||
// TODO: Things that should be done.
|
||||
//
|
||||
// * Wherever there's a function that take 2 or more arguments of the same type,
|
||||
// eg: fn new(total_size: gtk::Label, local_size: gtk::Label ..)
|
||||
// Wrap the types into Struct-tuples and imple deref so it won't be possible to pass
|
||||
// the wrong argument to the wrong position.
|
||||
|
||||
use chrono;
|
||||
use glib;
|
||||
use gtk;
|
||||
|
||||
use chrono::prelude::*;
|
||||
use gtk::prelude::*;
|
||||
use humansize::{file_size_opts as size_opts, FileSize};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
|
||||
// Declare a custom humansize option struct
|
||||
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
|
||||
Arc::new(size_opts::FileSizeOpts {
|
||||
divider: size_opts::Kilo::Binary,
|
||||
units: size_opts::Kilo::Decimal,
|
||||
decimal_places: 0,
|
||||
decimal_zeroes: 0,
|
||||
fixed_at: size_opts::FixedAt::No,
|
||||
long_units: false,
|
||||
space: true,
|
||||
suffix: "",
|
||||
allow_negative: false,
|
||||
})
|
||||
};
|
||||
|
||||
static ref NOW: DateTime<Utc> = Utc::now();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnInitialized;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shown;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hidden;
|
||||
|
||||
pub trait Visibility {}
|
||||
|
||||
impl Visibility for Shown {}
|
||||
impl Visibility for Hidden {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Normal;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GreyedOut;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Title<S> {
|
||||
title: gtk::Label,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S> Title<S> {
|
||||
#[allow(unused_must_use)]
|
||||
// This does not need to be &mut since gtk-rs does not model ownership
|
||||
// But I think it wouldn't hurt if we treat it as a Rust api.
|
||||
fn set_title(&mut self, s: &str) {
|
||||
self.title.set_text(s);
|
||||
}
|
||||
}
|
||||
|
||||
impl Title<Normal> {
|
||||
fn new(title: gtk::Label) -> Self {
|
||||
Title {
|
||||
title,
|
||||
state: Normal {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Title<Normal>> for Title<GreyedOut> {
|
||||
fn from(f: Title<Normal>) -> Self {
|
||||
f.title
|
||||
.get_style_context()
|
||||
.map(|c| c.add_class("dim-label"));
|
||||
|
||||
Title {
|
||||
title: f.title,
|
||||
state: GreyedOut {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Title<GreyedOut>> for Title<Normal> {
|
||||
fn from(f: Title<GreyedOut>) -> Self {
|
||||
f.title
|
||||
.get_style_context()
|
||||
.map(|c| c.remove_class("dim-label"));
|
||||
|
||||
Title {
|
||||
title: f.title,
|
||||
state: Normal {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TitleMachine {
|
||||
Normal(Title<Normal>),
|
||||
GreyedOut(Title<GreyedOut>),
|
||||
}
|
||||
|
||||
impl TitleMachine {
|
||||
pub fn new(label: gtk::Label, is_played: bool) -> Self {
|
||||
let m = TitleMachine::Normal(Title::<Normal>::new(label));
|
||||
m.determine_state(is_played)
|
||||
}
|
||||
|
||||
pub fn determine_state(self, is_played: bool) -> Self {
|
||||
use self::TitleMachine::*;
|
||||
|
||||
match (self, is_played) {
|
||||
(title @ Normal(_), false) => title,
|
||||
(title @ GreyedOut(_), true) => title,
|
||||
(Normal(val), true) => GreyedOut(val.into()),
|
||||
(GreyedOut(val), false) => Normal(val.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_title(&mut self, s: &str) {
|
||||
use self::TitleMachine::*;
|
||||
|
||||
match *self {
|
||||
Normal(ref mut val) => val.set_title(s),
|
||||
GreyedOut(ref mut val) => val.set_title(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Usual;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YearShown;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Date<S> {
|
||||
date: gtk::Label,
|
||||
epoch: i64,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S> Date<S> {
|
||||
fn into_usual(self, epoch: i64) -> Date<Usual> {
|
||||
let ts = Utc.timestamp(epoch, 0);
|
||||
self.date.set_text(ts.format("%e %b").to_string().trim());
|
||||
|
||||
Date {
|
||||
date: self.date,
|
||||
epoch: self.epoch,
|
||||
state: Usual {},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_year_shown(self, epoch: i64) -> Date<YearShown> {
|
||||
let ts = Utc.timestamp(epoch, 0);
|
||||
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
|
||||
|
||||
Date {
|
||||
date: self.date,
|
||||
epoch: self.epoch,
|
||||
state: YearShown {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Date<UnInitialized> {
|
||||
fn new(date: gtk::Label, epoch: i64) -> Self {
|
||||
let ts = Utc.timestamp(epoch, 0);
|
||||
date.set_text(ts.format("%e %b %Y").to_string().trim());
|
||||
|
||||
Date {
|
||||
date,
|
||||
epoch,
|
||||
state: UnInitialized {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DateMachine {
|
||||
UnInitialized(Date<UnInitialized>),
|
||||
Usual(Date<Usual>),
|
||||
WithYear(Date<YearShown>),
|
||||
}
|
||||
|
||||
impl DateMachine {
|
||||
pub fn new(label: gtk::Label, epoch: i64) -> Self {
|
||||
let m = DateMachine::UnInitialized(Date::<UnInitialized>::new(label, epoch));
|
||||
m.determine_state(epoch)
|
||||
}
|
||||
|
||||
pub fn determine_state(self, epoch: i64) -> Self {
|
||||
use self::DateMachine::*;
|
||||
|
||||
let ts = Utc.timestamp(epoch, 0);
|
||||
let is_old = NOW.year() != ts.year();
|
||||
|
||||
match (self, is_old) {
|
||||
// Into Usual
|
||||
(Usual(val), false) => Usual(val.into_usual(epoch)),
|
||||
(WithYear(val), false) => Usual(val.into_usual(epoch)),
|
||||
(UnInitialized(val), false) => Usual(val.into_usual(epoch)),
|
||||
|
||||
// Into Year Shown
|
||||
(Usual(val), true) => WithYear(val.into_year_shown(epoch)),
|
||||
(WithYear(val), true) => WithYear(val.into_year_shown(epoch)),
|
||||
(UnInitialized(val), true) => WithYear(val.into_year_shown(epoch)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Duration<S: Visibility> {
|
||||
// TODO: make duration and separator diff types
|
||||
duration: gtk::Label,
|
||||
separator: gtk::Label,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S: Visibility> Duration<S> {
|
||||
// This needs a better name.
|
||||
// TODO: make me mut
|
||||
fn set_duration(&self, minutes: i64) {
|
||||
self.duration.set_text(&format!("{} min", minutes));
|
||||
}
|
||||
}
|
||||
|
||||
impl Duration<Hidden> {
|
||||
fn new(duration: gtk::Label, separator: gtk::Label) -> Self {
|
||||
duration.hide();
|
||||
separator.hide();
|
||||
|
||||
Duration {
|
||||
duration,
|
||||
separator,
|
||||
state: Hidden {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Duration<Hidden>> for Duration<Shown> {
|
||||
fn from(f: Duration<Hidden>) -> Self {
|
||||
f.duration.show();
|
||||
f.separator.show();
|
||||
|
||||
Duration {
|
||||
duration: f.duration,
|
||||
separator: f.separator,
|
||||
state: Shown {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Duration<Shown>> for Duration<Hidden> {
|
||||
fn from(f: Duration<Shown>) -> Self {
|
||||
f.duration.hide();
|
||||
f.separator.hide();
|
||||
|
||||
Duration {
|
||||
duration: f.duration,
|
||||
separator: f.separator,
|
||||
state: Hidden {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DurationMachine {
|
||||
Hidden(Duration<Hidden>),
|
||||
Shown(Duration<Shown>),
|
||||
}
|
||||
|
||||
impl DurationMachine {
|
||||
pub fn new(duration: gtk::Label, separator: gtk::Label, seconds: Option<i32>) -> Self {
|
||||
let m = DurationMachine::Hidden(Duration::<Hidden>::new(duration, separator));
|
||||
m.determine_state(seconds)
|
||||
}
|
||||
|
||||
pub fn determine_state(self, seconds: Option<i32>) -> Self {
|
||||
match (self, seconds) {
|
||||
(d @ DurationMachine::Hidden(_), None) => d,
|
||||
(DurationMachine::Shown(val), None) => DurationMachine::Hidden(val.into()),
|
||||
(DurationMachine::Hidden(val), Some(s)) => {
|
||||
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
|
||||
if minutes == 0 {
|
||||
DurationMachine::Hidden(val)
|
||||
} else {
|
||||
val.set_duration(minutes);
|
||||
DurationMachine::Shown(val.into())
|
||||
}
|
||||
}
|
||||
(DurationMachine::Shown(val), Some(s)) => {
|
||||
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
|
||||
if minutes == 0 {
|
||||
DurationMachine::Hidden(val.into())
|
||||
} else {
|
||||
val.set_duration(minutes);
|
||||
DurationMachine::Shown(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Size<S> {
|
||||
size: gtk::Label,
|
||||
separator: gtk::Label,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S> Size<S> {
|
||||
fn set_size(self, s: &str) -> Size<Shown> {
|
||||
self.size.set_text(s);
|
||||
self.size.show();
|
||||
self.separator.show();
|
||||
Size {
|
||||
size: self.size,
|
||||
separator: self.separator,
|
||||
state: Shown {},
|
||||
}
|
||||
}
|
||||
|
||||
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
|
||||
// It might be possible to make a generic definition with Specialization.
|
||||
// https://github.com/rust-lang/rust/issues/31844
|
||||
fn into_shown(self) -> Size<Shown> {
|
||||
self.size.show();
|
||||
self.separator.show();
|
||||
|
||||
Size {
|
||||
size: self.size,
|
||||
separator: self.separator,
|
||||
state: Shown {},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_hidden(self) -> Size<Hidden> {
|
||||
self.size.hide();
|
||||
self.separator.hide();
|
||||
|
||||
Size {
|
||||
size: self.size,
|
||||
separator: self.separator,
|
||||
state: Hidden {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Size<UnInitialized> {
|
||||
fn new(size: gtk::Label, separator: gtk::Label) -> Self {
|
||||
size.hide();
|
||||
separator.hide();
|
||||
|
||||
Size {
|
||||
size,
|
||||
separator,
|
||||
state: UnInitialized {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pub trait Playable {}
|
||||
|
||||
// impl Playable for Download {}
|
||||
// impl Playable for Play {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Download;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Play;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
// FIXME: Needs better name.
|
||||
// Should each button also has it's own type and machine?
|
||||
pub struct DownloadPlay<S> {
|
||||
play: gtk::Button,
|
||||
download: gtk::Button,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S> DownloadPlay<S> {
|
||||
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
|
||||
// It might be possible to make a generic definition with Specialization.
|
||||
// https://github.com/rust-lang/rust/issues/31844
|
||||
fn into_playable(self) -> DownloadPlay<Play> {
|
||||
self.play.show();
|
||||
self.download.hide();
|
||||
|
||||
DownloadPlay {
|
||||
play: self.play,
|
||||
download: self.download,
|
||||
state: Play {},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_fetchable(self) -> DownloadPlay<Download> {
|
||||
self.play.hide();
|
||||
self.download.show();
|
||||
|
||||
DownloadPlay {
|
||||
play: self.play,
|
||||
download: self.download,
|
||||
state: Download {},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_hidden(self) -> DownloadPlay<Hidden> {
|
||||
self.play.hide();
|
||||
self.download.hide();
|
||||
|
||||
DownloadPlay {
|
||||
play: self.play,
|
||||
download: self.download,
|
||||
state: Hidden {},
|
||||
}
|
||||
}
|
||||
|
||||
fn download_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
self.download.connect_clicked(f)
|
||||
}
|
||||
|
||||
fn play_connect_clicked<F: Fn(>k::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.play.connect_clicked(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadPlay<UnInitialized> {
|
||||
fn new(play: gtk::Button, download: gtk::Button) -> Self {
|
||||
play.hide();
|
||||
download.hide();
|
||||
|
||||
DownloadPlay {
|
||||
play,
|
||||
download,
|
||||
state: UnInitialized {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Progress<S> {
|
||||
bar: gtk::ProgressBar,
|
||||
cancel: gtk::Button,
|
||||
local_size: gtk::Label,
|
||||
prog_separator: gtk::Label,
|
||||
state: S,
|
||||
}
|
||||
|
||||
impl<S> Progress<S> {
|
||||
fn into_shown(self) -> Progress<Shown> {
|
||||
self.bar.show();
|
||||
self.cancel.show();
|
||||
self.local_size.show();
|
||||
self.prog_separator.show();
|
||||
|
||||
Progress {
|
||||
bar: self.bar,
|
||||
cancel: self.cancel,
|
||||
local_size: self.local_size,
|
||||
prog_separator: self.prog_separator,
|
||||
state: Shown {},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_hidden(self) -> Progress<Hidden> {
|
||||
self.bar.hide();
|
||||
self.cancel.hide();
|
||||
self.local_size.hide();
|
||||
self.prog_separator.hide();
|
||||
|
||||
Progress {
|
||||
bar: self.bar,
|
||||
cancel: self.cancel,
|
||||
local_size: self.local_size,
|
||||
prog_separator: self.prog_separator,
|
||||
state: Hidden {},
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_must_use)]
|
||||
// This does not need to be &mut since gtk-rs does not model ownership
|
||||
// But I think it wouldn't hurt if we treat it as a Rust api.
|
||||
fn update_progress(&mut self, local_size: &str, fraction: f64) {
|
||||
self.local_size.set_text(local_size);
|
||||
self.bar.set_fraction(fraction);
|
||||
}
|
||||
|
||||
fn cancel_connect_clicked<F: Fn(>k::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.cancel.connect_clicked(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Progress<UnInitialized> {
|
||||
fn new(
|
||||
bar: gtk::ProgressBar,
|
||||
cancel: gtk::Button,
|
||||
local_size: gtk::Label,
|
||||
prog_separator: gtk::Label,
|
||||
) -> Self {
|
||||
bar.hide();
|
||||
cancel.hide();
|
||||
local_size.hide();
|
||||
prog_separator.hide();
|
||||
|
||||
Progress {
|
||||
bar,
|
||||
cancel,
|
||||
local_size,
|
||||
prog_separator,
|
||||
state: UnInitialized {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Media<X, Y, Z> {
|
||||
dl: DownloadPlay<X>,
|
||||
size: Size<Y>,
|
||||
progress: Progress<Z>,
|
||||
}
|
||||
|
||||
type New<Y> = Media<Download, Y, Hidden>;
|
||||
type Playable<Y> = Media<Play, Y, Hidden>;
|
||||
type InProgress = Media<Hidden, Shown, Shown>;
|
||||
|
||||
impl<X, Y, Z> Media<X, Y, Z> {
|
||||
fn set_size(self, s: &str) -> Media<X, Shown, Z> {
|
||||
Media {
|
||||
dl: self.dl,
|
||||
size: self.size.set_size(s),
|
||||
progress: self.progress,
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_size(self) -> Media<X, Hidden, Z> {
|
||||
Media {
|
||||
dl: self.dl,
|
||||
size: self.size.into_hidden(),
|
||||
progress: self.progress,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new(self, size: &str) -> New<Shown> {
|
||||
Media {
|
||||
dl: self.dl.into_fetchable(),
|
||||
size: self.size.set_size(size),
|
||||
progress: self.progress.into_hidden(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_without(self) -> New<Hidden> {
|
||||
Media {
|
||||
dl: self.dl.into_fetchable(),
|
||||
size: self.size.into_hidden(),
|
||||
progress: self.progress.into_hidden(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_playable(self, size: &str) -> Playable<Shown> {
|
||||
Media {
|
||||
dl: self.dl.into_playable(),
|
||||
size: self.size.set_size(size),
|
||||
progress: self.progress.into_hidden(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_playable_without(self) -> Playable<Hidden> {
|
||||
Media {
|
||||
dl: self.dl.into_playable(),
|
||||
size: self.size.into_hidden(),
|
||||
progress: self.progress.into_hidden(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<X, Z> Media<X, Shown, Z> {
|
||||
fn into_progress(self) -> InProgress {
|
||||
Media {
|
||||
dl: self.dl.into_hidden(),
|
||||
size: self.size.into_shown(),
|
||||
progress: self.progress.into_shown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<X, Z> Media<X, Hidden, Z> {
|
||||
fn into_progress(self) -> InProgress {
|
||||
Media {
|
||||
dl: self.dl.into_hidden(),
|
||||
size: self.size.set_size("Unkown"),
|
||||
progress: self.progress.into_shown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<X, Z> Media<X, UnInitialized, Z> {
|
||||
fn into_progress(self, size: Option<String>) -> InProgress {
|
||||
if let Some(s) = size {
|
||||
Media {
|
||||
dl: self.dl.into_hidden(),
|
||||
size: self.size.set_size(&s),
|
||||
progress: self.progress.into_shown(),
|
||||
}
|
||||
} else {
|
||||
Media {
|
||||
dl: self.dl.into_hidden(),
|
||||
size: self.size.set_size("Unkown"),
|
||||
progress: self.progress.into_shown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InProgress {
|
||||
#[allow(unused_must_use)]
|
||||
// This does not need to be &mut since gtk-rs does not model ownership
|
||||
// But I think it wouldn't hurt if we treat it as a Rust api.
|
||||
fn update_progress(&mut self, local_size: &str, fraction: f64) {
|
||||
self.progress.update_progress(local_size, fraction)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ButtonsState {
|
||||
New(Media<Download, Shown, Hidden>),
|
||||
NewWithoutSize(Media<Download, Hidden, Hidden>),
|
||||
Playable(Media<Play, Shown, Hidden>),
|
||||
PlayableWithoutSize(Media<Play, Hidden, Hidden>),
|
||||
}
|
||||
|
||||
impl ButtonsState {
|
||||
pub fn determine_state(self, size: Option<String>, is_downloaded: bool) -> Self {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match (self, size, is_downloaded) {
|
||||
// From whatever to New
|
||||
(New(m), Some(s), false) => New(m.into_new(&s)),
|
||||
(Playable(m), Some(s), false) => New(m.into_new(&s)),
|
||||
|
||||
(NewWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
|
||||
(PlayableWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
|
||||
|
||||
// From whatever to Playable
|
||||
(New(m), Some(s), true) => Playable(m.into_playable(&s)),
|
||||
(Playable(m), Some(s), true) => Playable(m.into_playable(&s)),
|
||||
|
||||
(NewWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
|
||||
(PlayableWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
|
||||
|
||||
// From whatever to NewWithoutSize
|
||||
(New(m), None, false) => NewWithoutSize(m.hide_size()),
|
||||
(Playable(m), None, false) => NewWithoutSize(m.into_new_without()),
|
||||
|
||||
(b @ NewWithoutSize(_), None, false) => b,
|
||||
(PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into_new_without()),
|
||||
|
||||
// From whatever to PlayableWithoutSize
|
||||
(New(m), None, true) => PlayableWithoutSize(m.into_playable_without()),
|
||||
(Playable(m), None, true) => PlayableWithoutSize(m.hide_size()),
|
||||
|
||||
(NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into_playable_without()),
|
||||
(b @ PlayableWithoutSize(_), None, true) => b,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_progress(self) -> InProgress {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match self {
|
||||
New(m) => m.into_progress(),
|
||||
Playable(m) => m.into_progress(),
|
||||
NewWithoutSize(m) => m.into_progress(),
|
||||
PlayableWithoutSize(m) => m.into_progress(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_size(self, size: Option<String>) -> Self {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match (self, size) {
|
||||
(New(m), Some(s)) => New(m.set_size(&s)),
|
||||
(New(m), None) => NewWithoutSize(m.hide_size()),
|
||||
(Playable(m), Some(s)) => Playable(m.set_size(&s)),
|
||||
(Playable(m), None) => PlayableWithoutSize(m.hide_size()),
|
||||
(bttn @ NewWithoutSize(_), None) => bttn,
|
||||
(bttn @ PlayableWithoutSize(_), None) => bttn,
|
||||
(NewWithoutSize(m), Some(s)) => New(m.into_new(&s)),
|
||||
(PlayableWithoutSize(m), Some(s)) => Playable(m.into_playable(&s)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn download_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match *self {
|
||||
New(ref val) => val.dl.download_connect_clicked(f),
|
||||
NewWithoutSize(ref val) => val.dl.download_connect_clicked(f),
|
||||
Playable(ref val) => val.dl.download_connect_clicked(f),
|
||||
PlayableWithoutSize(ref val) => val.dl.download_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match *self {
|
||||
New(ref val) => val.dl.play_connect_clicked(f),
|
||||
NewWithoutSize(ref val) => val.dl.play_connect_clicked(f),
|
||||
Playable(ref val) => val.dl.play_connect_clicked(f),
|
||||
PlayableWithoutSize(ref val) => val.dl.play_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_connect_clicked<F: Fn(>k::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
use self::ButtonsState::*;
|
||||
|
||||
match *self {
|
||||
New(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
Playable(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MediaMachine {
|
||||
UnInitialized(Media<UnInitialized, UnInitialized, UnInitialized>),
|
||||
Initialized(ButtonsState),
|
||||
InProgress(Media<Hidden, Shown, Shown>),
|
||||
}
|
||||
|
||||
impl MediaMachine {
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(too_many_arguments))]
|
||||
pub fn new(
|
||||
play: gtk::Button,
|
||||
download: gtk::Button,
|
||||
bar: gtk::ProgressBar,
|
||||
cancel: gtk::Button,
|
||||
total_size: gtk::Label,
|
||||
local_size: gtk::Label,
|
||||
separator: gtk::Label,
|
||||
prog_separator: gtk::Label,
|
||||
) -> Self {
|
||||
let dl = DownloadPlay::<UnInitialized>::new(play, download);
|
||||
let progress = Progress::<UnInitialized>::new(bar, cancel, local_size, prog_separator);
|
||||
let size = Size::<UnInitialized>::new(total_size, separator);
|
||||
|
||||
MediaMachine::UnInitialized(Media { dl, progress, size })
|
||||
}
|
||||
|
||||
pub fn download_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
use self::MediaMachine::*;
|
||||
|
||||
match *self {
|
||||
UnInitialized(ref val) => val.dl.download_connect_clicked(f),
|
||||
Initialized(ref val) => val.download_connect_clicked(f),
|
||||
InProgress(ref val) => val.dl.download_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
use self::MediaMachine::*;
|
||||
|
||||
match *self {
|
||||
UnInitialized(ref val) => val.dl.play_connect_clicked(f),
|
||||
Initialized(ref val) => val.play_connect_clicked(f),
|
||||
InProgress(ref val) => val.dl.play_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_connect_clicked<F: Fn(>k::Button) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
use self::MediaMachine::*;
|
||||
|
||||
match *self {
|
||||
UnInitialized(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
Initialized(ref val) => val.cancel_connect_clicked(f),
|
||||
InProgress(ref val) => val.progress.cancel_connect_clicked(f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn determine_state(self, bytes: Option<i32>, is_active: bool, is_downloaded: bool) -> Self {
|
||||
use self::ButtonsState::*;
|
||||
use self::MediaMachine::*;
|
||||
|
||||
match (self, size_helper(bytes), is_downloaded, is_active) {
|
||||
(UnInitialized(m), s, _, true) => InProgress(m.into_progress(s)),
|
||||
|
||||
// Into New
|
||||
(UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
|
||||
(UnInitialized(m), None, false, false) => {
|
||||
Initialized(NewWithoutSize(m.into_new_without()))
|
||||
}
|
||||
|
||||
// Into Playable
|
||||
(UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
|
||||
(UnInitialized(m), None, true, false) => {
|
||||
Initialized(PlayableWithoutSize(m.into_playable_without()))
|
||||
}
|
||||
|
||||
(Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)),
|
||||
(Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()),
|
||||
|
||||
// Into New
|
||||
(InProgress(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
|
||||
(InProgress(m), None, false, false) => {
|
||||
Initialized(NewWithoutSize(m.into_new_without()))
|
||||
}
|
||||
|
||||
// Into Playable
|
||||
(InProgress(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
|
||||
(InProgress(m), None, true, false) => {
|
||||
Initialized(PlayableWithoutSize(m.into_playable_without()))
|
||||
}
|
||||
|
||||
(i @ InProgress(_), _, _, _) => i,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_size(self, bytes: Option<i32>) -> Self {
|
||||
use self::MediaMachine::*;
|
||||
let size = size_helper(bytes);
|
||||
|
||||
match (self, size) {
|
||||
(Initialized(bttn), s) => Initialized(bttn.set_size(s)),
|
||||
(InProgress(val), Some(s)) => InProgress(val.set_size(&s)),
|
||||
(n @ InProgress(_), None) => n,
|
||||
(n @ UnInitialized(_), _) => n,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_progress(&mut self, local_size: &str, fraction: f64) {
|
||||
use self::MediaMachine::*;
|
||||
|
||||
match *self {
|
||||
Initialized(_) => (),
|
||||
UnInitialized(_) => (),
|
||||
InProgress(ref mut val) => val.update_progress(local_size, fraction),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size_helper(bytes: Option<i32>) -> Option<String> {
|
||||
let s = bytes?;
|
||||
if s == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
s.file_size(SIZE_OPTS.clone()).ok()
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
use chrono::prelude::*;
|
||||
use failure::Error;
|
||||
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::EpisodeWidgetQuery;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use app::Action;
|
||||
use utils::{self, lazy_load_full};
|
||||
use widgets::EpisodeWidget;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
|
||||
Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ListSplit {
|
||||
Today,
|
||||
Yday,
|
||||
Week,
|
||||
Month,
|
||||
Rest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomeView {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
frame_parent: gtk::Box,
|
||||
today_box: gtk::Box,
|
||||
yday_box: gtk::Box,
|
||||
week_box: gtk::Box,
|
||||
month_box: gtk::Box,
|
||||
rest_box: gtk::Box,
|
||||
today_list: gtk::ListBox,
|
||||
yday_list: gtk::ListBox,
|
||||
week_list: gtk::ListBox,
|
||||
month_list: gtk::ListBox,
|
||||
rest_list: gtk::ListBox,
|
||||
}
|
||||
|
||||
impl Default for HomeView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
|
||||
let today_box: gtk::Box = builder.get_object("today_box").unwrap();
|
||||
let yday_box: gtk::Box = builder.get_object("yday_box").unwrap();
|
||||
let week_box: gtk::Box = builder.get_object("week_box").unwrap();
|
||||
let month_box: gtk::Box = builder.get_object("month_box").unwrap();
|
||||
let rest_box: gtk::Box = builder.get_object("rest_box").unwrap();
|
||||
let today_list: gtk::ListBox = builder.get_object("today_list").unwrap();
|
||||
let yday_list: gtk::ListBox = builder.get_object("yday_list").unwrap();
|
||||
let week_list: gtk::ListBox = builder.get_object("week_list").unwrap();
|
||||
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
|
||||
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
|
||||
|
||||
HomeView {
|
||||
container,
|
||||
scrolled_window,
|
||||
frame_parent,
|
||||
today_box,
|
||||
yday_box,
|
||||
week_box,
|
||||
month_box,
|
||||
rest_box,
|
||||
today_list,
|
||||
yday_list,
|
||||
week_list,
|
||||
month_list,
|
||||
rest_list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: REFACTOR ME
|
||||
impl HomeView {
|
||||
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
|
||||
use self::ListSplit::*;
|
||||
|
||||
let view = Rc::new(HomeView::default());
|
||||
let ignore = utils::get_ignored_shows()?;
|
||||
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
|
||||
let now_utc = Utc::now();
|
||||
|
||||
let view_ = view.clone();
|
||||
let func = move |ep: EpisodeWidgetQuery| {
|
||||
let epoch = ep.epoch();
|
||||
let widget = HomeEpisode::new(ep, &sender);
|
||||
|
||||
match split(&now_utc, i64::from(epoch)) {
|
||||
Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
|
||||
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
|
||||
Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
|
||||
Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
|
||||
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
|
||||
}
|
||||
};
|
||||
|
||||
let view_ = view.clone();
|
||||
let callback = move || {
|
||||
view_
|
||||
.set_vadjustment()
|
||||
.map_err(|err| format!("{}", err))
|
||||
.ok();
|
||||
};
|
||||
|
||||
lazy_load_full(episodes, func, callback);
|
||||
view.container.show_all();
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
fn set_vadjustment(&self) -> Result<(), Error> {
|
||||
let guard = EPISODES_VIEW_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some(ref sendcell) = *guard {
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the vertical scrollbar position.
|
||||
pub fn save_alignment(&self) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = EPISODES_VIEW_VALIGNMENT.lock() {
|
||||
let adj = self.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some(SendCell::new(adj));
|
||||
info!("Saved episodes_view alignment.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn add_to_box(widget: &HomeEpisode, listbox: >k::ListBox, box_: >k::Box) {
|
||||
listbox.add(&widget.container);
|
||||
box_.show();
|
||||
}
|
||||
|
||||
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
|
||||
let ep = Utc.timestamp(epoch, 0);
|
||||
|
||||
if now.ordinal() == ep.ordinal() && now.year() == ep.year() {
|
||||
ListSplit::Today
|
||||
} else if now.ordinal() == ep.ordinal() + 1 && now.year() == ep.year() {
|
||||
ListSplit::Yday
|
||||
} else if now.iso_week().week() == ep.iso_week().week() && now.year() == ep.year() {
|
||||
ListSplit::Week
|
||||
} else if now.month() == ep.month() && now.year() == ep.year() {
|
||||
ListSplit::Month
|
||||
} else {
|
||||
ListSplit::Rest
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HomeEpisode {
|
||||
container: gtk::Box,
|
||||
image: gtk::Image,
|
||||
episode: gtk::Box,
|
||||
}
|
||||
|
||||
impl Default for HomeEpisode {
|
||||
fn default() -> Self {
|
||||
let builder =
|
||||
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let ep = EpisodeWidget::default();
|
||||
container.pack_start(&ep.container, true, true, 6);
|
||||
|
||||
HomeEpisode {
|
||||
container,
|
||||
image,
|
||||
episode: ep.container,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HomeEpisode {
|
||||
fn new(episode: EpisodeWidgetQuery, sender: &Sender<Action>) -> HomeEpisode {
|
||||
let builder =
|
||||
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let pid = episode.podcast_id();
|
||||
let ep = EpisodeWidget::new(episode, sender);
|
||||
|
||||
let view = HomeEpisode {
|
||||
container,
|
||||
image,
|
||||
episode: ep.container,
|
||||
};
|
||||
|
||||
view.init(pid);
|
||||
view
|
||||
}
|
||||
|
||||
fn init(&self, podcast_id: i32) {
|
||||
self.set_cover(podcast_id)
|
||||
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||
.ok();
|
||||
|
||||
self.container.pack_start(&self.episode, true, true, 6);
|
||||
}
|
||||
|
||||
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
|
||||
utils::set_image_from_path(&self.image, podcast_id, 64)
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
mod empty;
|
||||
mod episode;
|
||||
mod episode_states;
|
||||
mod home_view;
|
||||
mod show;
|
||||
mod shows_view;
|
||||
|
||||
pub use self::empty::EmptyView;
|
||||
pub use self::episode::EpisodeWidget;
|
||||
pub use self::home_view::HomeView;
|
||||
pub use self::show::ShowWidget;
|
||||
pub use self::show::{mark_all_notif, remove_show_notif};
|
||||
pub use self::shows_view::ShowsView;
|
||||
@ -1,347 +0,0 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use html2pango::markup_from_raw;
|
||||
use open;
|
||||
use rayon;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::utils::delete_show;
|
||||
use hammond_data::Podcast;
|
||||
|
||||
use app::Action;
|
||||
use appnotif::{InAppNotification, UndoState};
|
||||
use utils::{self, lazy_load};
|
||||
use widgets::EpisodeWidget;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{SendError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
lazy_static! {
|
||||
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
|
||||
Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowWidget {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
cover: gtk::Image,
|
||||
description: gtk::Label,
|
||||
link: gtk::Button,
|
||||
settings: gtk::MenuButton,
|
||||
unsub: gtk::Button,
|
||||
episodes: gtk::ListBox,
|
||||
podcast_id: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for ShowWidget {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let episodes = builder.get_object("episodes").unwrap();
|
||||
|
||||
let cover: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let description: gtk::Label = builder.get_object("description").unwrap();
|
||||
let unsub: gtk::Button = builder.get_object("unsub_button").unwrap();
|
||||
let link: gtk::Button = builder.get_object("link_button").unwrap();
|
||||
let settings: gtk::MenuButton = builder.get_object("settings_button").unwrap();
|
||||
|
||||
ShowWidget {
|
||||
container,
|
||||
scrolled_window,
|
||||
cover,
|
||||
description,
|
||||
unsub,
|
||||
link,
|
||||
settings,
|
||||
episodes,
|
||||
podcast_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowWidget {
|
||||
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> Rc<ShowWidget> {
|
||||
let mut pdw = ShowWidget::default();
|
||||
pdw.init(&pd, &sender);
|
||||
let pdw = Rc::new(pdw);
|
||||
populate_listbox(&pdw, pd, sender)
|
||||
.map_err(|err| error!("Failed to populate the listbox: {}", err))
|
||||
.ok();
|
||||
|
||||
pdw
|
||||
}
|
||||
|
||||
pub fn init(&mut self, pd: &Arc<Podcast>, sender: &Sender<Action>) {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
|
||||
|
||||
self.unsub
|
||||
.connect_clicked(clone!(pd, sender => move |bttn| {
|
||||
on_unsub_button_clicked(pd.clone(), bttn, &sender);
|
||||
}));
|
||||
|
||||
self.set_description(pd.description());
|
||||
self.podcast_id = Some(pd.id());
|
||||
|
||||
self.set_cover(&pd)
|
||||
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||
.ok();
|
||||
|
||||
let link = pd.link().to_owned();
|
||||
self.link.set_tooltip_text(Some(link.as_str()));
|
||||
self.link.connect_clicked(move |_| {
|
||||
info!("Opening link: {}", &link);
|
||||
open::that(&link)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Failed open link: {}", &link))
|
||||
.ok();
|
||||
});
|
||||
|
||||
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
|
||||
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
|
||||
|
||||
let episodes = self.episodes.clone();
|
||||
mark_all.connect_clicked(clone!(pd, sender => move |_| {
|
||||
on_played_button_clicked(
|
||||
pd.clone(),
|
||||
&episodes,
|
||||
&sender
|
||||
)
|
||||
}));
|
||||
self.settings.set_popover(&show_menu);
|
||||
}
|
||||
|
||||
/// Set the show cover.
|
||||
fn set_cover(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
|
||||
utils::set_image_from_path(&self.cover, pd.id(), 256)
|
||||
}
|
||||
|
||||
/// Set the descripton text.
|
||||
fn set_description(&self, text: &str) {
|
||||
self.description.set_markup(&markup_from_raw(text));
|
||||
}
|
||||
|
||||
/// Save the scrollabar vajustment to the cache.
|
||||
pub fn save_vadjustment(&self, oldid: i32) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = SHOW_WIDGET_VALIGNMENT.lock() {
|
||||
let adj = self.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some((oldid, SendCell::new(adj)));
|
||||
debug!("Widget Alignment was saved with ID: {}.", oldid);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
fn set_vadjustment(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
|
||||
let guard = SHOW_WIDGET_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some((oldid, ref sendcell)) = *guard {
|
||||
// Only copy the old scrollbar if both widget's represent the same podcast.
|
||||
debug!("PID: {}", pd.id());
|
||||
debug!("OLDID: {}", oldid);
|
||||
if pd.id() != oldid {
|
||||
debug!("Early return");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn podcast_id(&self) -> Option<i32> {
|
||||
self.podcast_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate the listbox with the shows episodes.
|
||||
fn populate_listbox(
|
||||
show: &Rc<ShowWidget>,
|
||||
pd: Arc<Podcast>,
|
||||
sender: Sender<Action>,
|
||||
) -> Result<(), Error> {
|
||||
use crossbeam_channel::bounded;
|
||||
use crossbeam_channel::TryRecvError::*;
|
||||
|
||||
let count = dbqueries::get_pd_episodes_count(&pd)?;
|
||||
|
||||
let (sender_, receiver) = bounded(1);
|
||||
rayon::spawn(clone!(pd => move || {
|
||||
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
|
||||
// The receiver can be dropped if there's an early return
|
||||
// like on show without episodes for example.
|
||||
sender_.send(episodes).ok();
|
||||
}));
|
||||
|
||||
if count == 0 {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_show.ui");
|
||||
let container: gtk::Box = builder.get_object("empty_show").unwrap();
|
||||
show.episodes.add(&container);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let show_ = show.clone();
|
||||
gtk::idle_add(move || {
|
||||
let episodes = match receiver.try_recv() {
|
||||
Ok(e) => e,
|
||||
Err(Empty) => return glib::Continue(true),
|
||||
Err(Disconnected) => return glib::Continue(false),
|
||||
};
|
||||
|
||||
let list = show_.episodes.clone();
|
||||
|
||||
let constructor = clone!(sender => move |ep| {
|
||||
EpisodeWidget::new(ep, &sender).container
|
||||
});
|
||||
|
||||
let callback = clone!(pd, show_ => move || {
|
||||
show_.set_vadjustment(&pd)
|
||||
.map_err(|err| error!("Failed to set ShowWidget Alignment: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
lazy_load(episodes, list.clone(), constructor, callback);
|
||||
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_unsub_button_clicked(pd: Arc<Podcast>, unsub_button: >k::Button, sender: &Sender<Action>) {
|
||||
// hack to get away without properly checking for none.
|
||||
// if pressed twice would panic.
|
||||
unsub_button.set_sensitive(false);
|
||||
|
||||
let wrap = || -> Result<(), SendError<_>> {
|
||||
sender.send(Action::RemoveShow(pd))?;
|
||||
|
||||
sender.send(Action::HeaderBarNormal)?;
|
||||
sender.send(Action::ShowShowsAnimated)?;
|
||||
// Queue a refresh after the switch to avoid blocking the db.
|
||||
sender.send(Action::RefreshShowsView)?;
|
||||
sender.send(Action::RefreshEpisodesView)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
wrap().map_err(|err| error!("Action Sender: {}", err)).ok();
|
||||
unsub_button.set_sensitive(true);
|
||||
}
|
||||
|
||||
fn on_played_button_clicked(pd: Arc<Podcast>, episodes: >k::ListBox, sender: &Sender<Action>) {
|
||||
if dim_titles(episodes).is_none() {
|
||||
error!("Something went horribly wrong when dimming the titles.");
|
||||
warn!("RUN WHILE YOU STILL CAN!");
|
||||
}
|
||||
|
||||
sender
|
||||
.send(Action::MarkAllPlayerNotification(pd))
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn mark_all_watched(pd: &Podcast, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
dbqueries::update_none_to_played_now(pd)?;
|
||||
// Not all widgets migth have been loaded when the mark_all is hit
|
||||
// So we will need to refresh again after it's done.
|
||||
sender.send(Action::RefreshWidgetIfSame(pd.id()))?;
|
||||
sender.send(Action::RefreshEpisodesView).map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn mark_all_notif(pd: Arc<Podcast>, sender: &Sender<Action>) -> InAppNotification {
|
||||
let id = pd.id();
|
||||
let callback = clone!(sender => move || {
|
||||
mark_all_watched(&pd, &sender)
|
||||
.map_err(|err| error!("Notif Callback Error: {}", err))
|
||||
.ok();
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
let undo_callback = clone!(sender => move || {
|
||||
sender.send(Action::RefreshWidgetIfSame(id))
|
||||
.map_err(|err| error!("Action Sender: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
let text = "Marked all episodes as listened";
|
||||
InAppNotification::new(text, callback, undo_callback, UndoState::Shown)
|
||||
}
|
||||
|
||||
pub fn remove_show_notif(pd: Arc<Podcast>, sender: Sender<Action>) -> InAppNotification {
|
||||
let text = format!("Unsubscribed from {}", pd.title());
|
||||
|
||||
utils::ignore_show(pd.id())
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Could not insert {} to the ignore list.", pd.title()))
|
||||
.ok();
|
||||
|
||||
let callback = clone!(pd, sender => move || {
|
||||
utils::uningore_show(pd.id())
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Could not remove {} from the ignore list.", pd.title()))
|
||||
.ok();
|
||||
|
||||
// Spawn a thread so it won't block the ui.
|
||||
rayon::spawn(clone!(pd, sender => move || {
|
||||
delete_show(&pd)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Failed to delete {}", pd.title()))
|
||||
.ok();
|
||||
|
||||
sender.send(Action::RefreshEpisodesView).ok();
|
||||
}));
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
let undo_wrap = move || -> Result<(), Error> {
|
||||
utils::uningore_show(pd.id())?;
|
||||
sender.send(Action::RefreshShowsView)?;
|
||||
sender.send(Action::RefreshEpisodesView)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let undo_callback = move || {
|
||||
undo_wrap().map_err(|err| error!("{}", err)).ok();
|
||||
};
|
||||
|
||||
InAppNotification::new(&text, callback, undo_callback, UndoState::Shown)
|
||||
}
|
||||
|
||||
// Ideally if we had a custom widget this would have been as simple as:
|
||||
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
|
||||
// But now I can't think of a better way to do it than hardcoding the title
|
||||
// position relative to the EpisodeWidget container gtk::Box.
|
||||
fn dim_titles(episodes: >k::ListBox) -> Option<()> {
|
||||
let children = episodes.get_children();
|
||||
|
||||
for row in children {
|
||||
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
|
||||
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
|
||||
let foo = container
|
||||
.get_children()
|
||||
.remove(0)
|
||||
.downcast::<gtk::Box>()
|
||||
.ok()?;
|
||||
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
|
||||
let baz = bar.get_children().remove(0).downcast::<gtk::Box>().ok()?;
|
||||
let title = baz.get_children().remove(0).downcast::<gtk::Label>().ok()?;
|
||||
|
||||
title.get_style_context().map(|c| c.add_class("dim-label"));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::Podcast;
|
||||
|
||||
use app::Action;
|
||||
use utils::{self, get_ignored_shows, lazy_load, set_image_from_path};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static! {
|
||||
static ref SHOWS_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowsView {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
flowbox: gtk::FlowBox,
|
||||
}
|
||||
|
||||
impl Default for ShowsView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_view.ui");
|
||||
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
|
||||
|
||||
ShowsView {
|
||||
container,
|
||||
scrolled_window,
|
||||
flowbox,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowsView {
|
||||
pub fn new(sender: Sender<Action>) -> Result<Rc<Self>, Error> {
|
||||
let pop = Rc::new(ShowsView::default());
|
||||
pop.init(sender);
|
||||
// Populate the flowbox with the Podcasts.
|
||||
populate_flowbox(&pop)?;
|
||||
Ok(pop)
|
||||
}
|
||||
|
||||
pub fn init(&self, sender: Sender<Action>) {
|
||||
self.flowbox.connect_child_activated(move |_, child| {
|
||||
on_child_activate(child, &sender)
|
||||
.map_err(|err| error!("Error along flowbox child activation: {}", err))
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
#[allow(unused)]
|
||||
fn set_vadjustment(&self) -> Result<(), Error> {
|
||||
let guard = SHOWS_VIEW_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some(ref sendcell) = *guard {
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the vertical scrollbar position.
|
||||
pub fn save_alignment(&self) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = SHOWS_VIEW_VALIGNMENT.lock() {
|
||||
let adj = self.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some(SendCell::new(adj));
|
||||
info!("Saved episodes_view alignment.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_flowbox(shows: &Rc<ShowsView>) -> Result<(), Error> {
|
||||
let ignore = get_ignored_shows()?;
|
||||
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
|
||||
|
||||
let constructor = move |parent| ShowsChild::new(&parent).child;
|
||||
let callback = clone!(shows => move || {
|
||||
shows.set_vadjustment()
|
||||
.map_err(|err| error!("Failed to set ShowsView Alignment: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
let flowbox = shows.flowbox.clone();
|
||||
lazy_load(podcasts, flowbox, constructor, callback);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_child_activate(child: >k::FlowBoxChild, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
use gtk::WidgetExt;
|
||||
|
||||
// This is such an ugly hack...
|
||||
let id = WidgetExt::get_name(child)
|
||||
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
|
||||
.parse::<i32>()?;
|
||||
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
|
||||
|
||||
sender.send(Action::HeaderBarShowTile(pd.title().into()))?;
|
||||
sender.send(Action::ReplaceWidget(pd))?;
|
||||
sender.send(Action::ShowWidgetAnimated)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShowsChild {
|
||||
container: gtk::Box,
|
||||
cover: gtk::Image,
|
||||
child: gtk::FlowBoxChild,
|
||||
}
|
||||
|
||||
impl Default for ShowsChild {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_child.ui");
|
||||
|
||||
let container: gtk::Box = builder.get_object("fb_child").unwrap();
|
||||
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.add(&container);
|
||||
|
||||
ShowsChild {
|
||||
container,
|
||||
cover,
|
||||
child,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowsChild {
|
||||
pub fn new(pd: &Podcast) -> ShowsChild {
|
||||
let child = ShowsChild::default();
|
||||
child.init(pd);
|
||||
child
|
||||
}
|
||||
|
||||
fn init(&self, pd: &Podcast) {
|
||||
self.container.set_tooltip_text(pd.title());
|
||||
WidgetExt::set_name(&self.child, &pd.id().to_string());
|
||||
|
||||
self.set_cover(pd.id())
|
||||
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
|
||||
set_image_from_path(&self.cover, podcast_id, 256)
|
||||
}
|
||||
}
|
||||
106
meson.build
106
meson.build
@ -1,38 +1,90 @@
|
||||
# Adatped from:
|
||||
# https://gitlab.gnome.org/danigm/fractal/blob/6e2911f9d2353c99a18a6c19fab7f903c4bbb431/meson.build
|
||||
|
||||
project(
|
||||
'hammond', 'rust',
|
||||
version: '0.3.3',
|
||||
'gnome-podcasts', 'rust',
|
||||
version: '0.4.7',
|
||||
license: 'GPLv3',
|
||||
)
|
||||
|
||||
hammond_version = meson.project_version()
|
||||
version_array = hammond_version.split('.')
|
||||
hammond_major_version = version_array[0].to_int()
|
||||
hammond_minor_version = version_array[1].to_int()
|
||||
hammond_version_micro = version_array[2].to_int()
|
||||
dependency('sqlite3', version: '>= 3.20')
|
||||
dependency('openssl', version: '>= 1.0')
|
||||
dependency('dbus-1')
|
||||
|
||||
hammond_prefix = get_option('prefix')
|
||||
hammond_bindir = join_paths(hammond_prefix, get_option('bindir'))
|
||||
dependency('glib-2.0', version: '>= 2.56')
|
||||
dependency('gio-2.0', version: '>= 2.56')
|
||||
dependency('gdk-pixbuf-2.0')
|
||||
dependency('gtk+-3.0', version: '>= 3.24.11')
|
||||
dependency('libhandy-0.0', version: '>= 0.0.13')
|
||||
|
||||
dependency('gstreamer-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-base-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-audio-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-video-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-player-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-plugins-base-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-plugins-bad-1.0', version: '>= 1.16')
|
||||
dependency('gstreamer-bad-audio-1.0', version: '>= 1.16')
|
||||
|
||||
cargo = find_program('cargo', required: true)
|
||||
gresource = find_program('glib-compile-resources', required: true)
|
||||
gschemas = find_program('glib-compile-schemas', required: true)
|
||||
|
||||
if get_option('profile') == 'development'
|
||||
profile = '.Devel'
|
||||
vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip()
|
||||
if vcs_tag == ''
|
||||
version_suffix = '-devel'
|
||||
else
|
||||
version_suffix = '-@0@'.format (vcs_tag)
|
||||
endif
|
||||
else
|
||||
profile = ''
|
||||
version_suffix = ''
|
||||
endif
|
||||
|
||||
podcast_toml = files(
|
||||
'Cargo.toml',
|
||||
'Cargo.lock',
|
||||
'podcasts-data/Cargo.toml',
|
||||
'podcasts-downloader/Cargo.toml',
|
||||
'podcasts-gtk/Cargo.toml',
|
||||
)
|
||||
|
||||
application_id = 'org.gnome.Podcasts@0@'.format(profile)
|
||||
i18n = import('i18n')
|
||||
gnome = import('gnome')
|
||||
|
||||
subdir('podcasts-gtk/po')
|
||||
podir = join_paths (meson.source_root (), 'podcasts-gtk', 'po')
|
||||
|
||||
podcasts_version = meson.project_version()
|
||||
|
||||
podcasts_prefix = get_option('prefix')
|
||||
podcasts_bindir = join_paths(podcasts_prefix, get_option('bindir'))
|
||||
podcasts_localedir = join_paths(podcasts_prefix, get_option('localedir'))
|
||||
|
||||
podcasts_conf = configuration_data()
|
||||
podcasts_conf.set('appid', application_id)
|
||||
podcasts_conf.set('bindir', podcasts_bindir)
|
||||
|
||||
datadir = get_option('datadir')
|
||||
icondir = join_paths(datadir, 'icons')
|
||||
subdir('hammond-gtk/resources')
|
||||
subdir('podcasts-gtk/resources')
|
||||
|
||||
cargo = find_program('cargo', required: false)
|
||||
gresource = find_program('glib-compile-resources', required: false)
|
||||
cargo_vendor = find_program('cargo-vendor', required: false)
|
||||
cargo_script = find_program('scripts/cargo.sh')
|
||||
test_script = find_program('scripts/test.sh')
|
||||
|
||||
cargo_release = custom_target('cargo-build',
|
||||
build_by_default: true,
|
||||
build_always: true,
|
||||
output: ['hammond'],
|
||||
install: true,
|
||||
install_dir: hammond_bindir,
|
||||
command: [cargo_script, '@CURRENT_SOURCE_DIR@', '@OUTPUT@'])
|
||||
subdir('podcasts-data/src')
|
||||
subdir('podcasts-downloader/src')
|
||||
subdir('podcasts-gtk/src')
|
||||
|
||||
run_target('release', command: ['scripts/release.sh',
|
||||
meson.project_name() + '-' + hammond_version
|
||||
])
|
||||
meson.add_dist_script(
|
||||
'scripts/dist-vendor.sh',
|
||||
meson.source_root(),
|
||||
join_paths(meson.build_root(), 'meson-dist', meson.project_name() + '-' + podcasts_version)
|
||||
)
|
||||
|
||||
test(
|
||||
'cargo-test',
|
||||
test_script,
|
||||
args: meson.build_root(),
|
||||
workdir: meson.source_root(),
|
||||
timeout: 3000
|
||||
)
|
||||
|
||||
9
meson_options.txt
Normal file
9
meson_options.txt
Normal file
@ -0,0 +1,9 @@
|
||||
option (
|
||||
'profile',
|
||||
type: 'combo',
|
||||
choices: [
|
||||
'default',
|
||||
'development'
|
||||
],
|
||||
value: 'default'
|
||||
)
|
||||
@ -1,49 +0,0 @@
|
||||
{
|
||||
"app-id" : "org.gnome.Hammond",
|
||||
"runtime" : "org.gnome.Platform",
|
||||
"runtime-version" : "master",
|
||||
"sdk" : "org.gnome.Sdk",
|
||||
"sdk-extensions" : [
|
||||
"org.freedesktop.Sdk.Extension.rust-stable"
|
||||
],
|
||||
"command" : "hammond",
|
||||
"tags" : [
|
||||
"nightly"
|
||||
],
|
||||
"desktop-file-name-prefix" : "(Nightly) ",
|
||||
"finish-args" : [
|
||||
"--filesystem=xdg-run/dconf",
|
||||
"--filesystem=~/.config/dconf:ro",
|
||||
"--talk-name=ca.desrt.dconf",
|
||||
"--env=DCONF_USER_CONFIG_DIR=.config/dconf",
|
||||
"--share=network",
|
||||
"--share=ipc",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--talk-name=org.freedesktop.Desktop"
|
||||
],
|
||||
"build-options" : {
|
||||
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
||||
"build-args" : [
|
||||
"--share=network"
|
||||
],
|
||||
"env" : {
|
||||
"CARGO_HOME" : "/run/build/Hammond/cargo",
|
||||
"RUST_BACKTRACE" : "1",
|
||||
"RUSTFLAGS" : "--cfg rayon_unstable"
|
||||
}
|
||||
},
|
||||
"modules" : [
|
||||
{
|
||||
"name" : "hammond",
|
||||
"buildsystem" : "meson",
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "https://gitlab.gnome.org/World/hammond.git",
|
||||
"branch" : "master"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
72
org.gnome.Podcasts.Devel.json
Normal file
72
org.gnome.Podcasts.Devel.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"app-id" : "org.gnome.Podcasts.Devel",
|
||||
"runtime" : "org.gnome.Platform",
|
||||
"runtime-version" : "3.36",
|
||||
"sdk" : "org.gnome.Sdk",
|
||||
"sdk-extensions" : [
|
||||
"org.freedesktop.Sdk.Extension.rust-stable"
|
||||
],
|
||||
"command" : "gnome-podcasts",
|
||||
"tags" : [
|
||||
"nightly"
|
||||
],
|
||||
"finish-args" : [
|
||||
"--share=network",
|
||||
"--share=ipc",
|
||||
"--socket=x11",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=wayland",
|
||||
"--socket=pulseaudio",
|
||||
"--env=USE_PLAYBING3=1"
|
||||
],
|
||||
"build-options" : {
|
||||
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
||||
"build-args" : [
|
||||
"--share=network"
|
||||
],
|
||||
"env" : {
|
||||
"CARGO_HOME" : "/run/build/Podcasts/cargo",
|
||||
"RUSTFLAGS" : "",
|
||||
"RUST_BACKTRACE" : "1"
|
||||
}
|
||||
},
|
||||
"modules" : [
|
||||
{
|
||||
"name" : "libhandy",
|
||||
"buildsystem" : "meson",
|
||||
"config-opts" : [
|
||||
"-Dintrospection=disabled",
|
||||
"-Dgtk_doc=false",
|
||||
"-Dtests=false",
|
||||
"-Dexamples=false",
|
||||
"-Dvapi=false",
|
||||
"-Dglade_catalog=disabled"
|
||||
],
|
||||
"cleanup" : [
|
||||
"/include",
|
||||
"/lib/pkgconfig"
|
||||
],
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "https://source.puri.sm/Librem5/libhandy.git",
|
||||
"tag" : "v0.0.13"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : "gnome-podcasts",
|
||||
"buildsystem" : "meson",
|
||||
"builddir" : "true",
|
||||
"config-opts" : [
|
||||
"-Dprofile=development"
|
||||
],
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "https://gitlab.gnome.org/World/podcasts.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
69
org.gnome.Podcasts.json
Normal file
69
org.gnome.Podcasts.json
Normal file
@ -0,0 +1,69 @@
|
||||
{
|
||||
"app-id" : "org.gnome.Podcasts",
|
||||
"runtime" : "org.gnome.Platform",
|
||||
"runtime-version" : "3.36",
|
||||
"sdk" : "org.gnome.Sdk",
|
||||
"sdk-extensions" : [
|
||||
"org.freedesktop.Sdk.Extension.rust-stable"
|
||||
],
|
||||
"command" : "gnome-podcasts",
|
||||
"tags" : [
|
||||
"nightly"
|
||||
],
|
||||
"desktop-file-name-suffix" : " ☢️",
|
||||
"finish-args" : [
|
||||
"--share=network",
|
||||
"--share=ipc",
|
||||
"--socket=x11",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=wayland",
|
||||
"--socket=pulseaudio",
|
||||
"--env=USE_PLAYBING3=1"
|
||||
],
|
||||
"build-options" : {
|
||||
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
||||
"build-args" : [
|
||||
"--share=network"
|
||||
],
|
||||
"env" : {
|
||||
"CARGO_HOME" : "/run/build/Podcasts/cargo",
|
||||
"RUST_BACKTRACE" : "1"
|
||||
}
|
||||
},
|
||||
"modules" : [
|
||||
{
|
||||
"name" : "libhandy",
|
||||
"buildsystem" : "meson",
|
||||
"config-opts" : [
|
||||
"-Dintrospection=disabled",
|
||||
"-Dgtk_doc=false",
|
||||
"-Dtests=false",
|
||||
"-Dexamples=false",
|
||||
"-Dvapi=false",
|
||||
"-Dglade_catalog=disabled"
|
||||
],
|
||||
"cleanup" : [
|
||||
"/include",
|
||||
"/lib/pkgconfig"
|
||||
],
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "https://source.puri.sm/Librem5/libhandy.git",
|
||||
"tag" : "v0.0.13"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : "gnome-podcasts",
|
||||
"builddir" : "true",
|
||||
"buildsystem" : "meson",
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "https://gitlab.gnome.org/World/podcasts.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
42
podcasts-data/Cargo.toml
Normal file
42
podcasts-data/Cargo.toml
Normal file
@ -0,0 +1,42 @@
|
||||
[package]
|
||||
authors = ["Jordan Petridis <jpetridis@gnome.org>"]
|
||||
name = "podcasts-data"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
ammonia = "3.1.0"
|
||||
chrono = "0.4.11"
|
||||
derive_builder = "0.9.0"
|
||||
lazy_static = "1.4.0"
|
||||
log = "0.4.8"
|
||||
rayon = "1.3.1"
|
||||
rfc822_sanitizer = "0.3.3"
|
||||
rss = "1.9.0"
|
||||
url = "2.1.1"
|
||||
xdg = "2.2.0"
|
||||
xml-rs = "0.8.3"
|
||||
futures = "0.1.29"
|
||||
hyper = "0.12.35"
|
||||
http = "0.1.19"
|
||||
tokio = "0.1.22"
|
||||
hyper-tls = "0.3.2"
|
||||
native-tls = "0.2.3"
|
||||
num_cpus = "1.13.0"
|
||||
failure = "0.1.8"
|
||||
failure_derive = "0.1.8"
|
||||
base64 = "0.12.2"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["sqlite", "r2d2"]
|
||||
version = "1.4.5"
|
||||
|
||||
[dependencies.diesel_migrations]
|
||||
features = ["sqlite"]
|
||||
version = "1.4.0"
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.7.2"
|
||||
tempdir = "0.3.7"
|
||||
pretty_assertions = "0.6.1"
|
||||
maplit = "1.0.2"
|
||||
6
podcasts-data/diesel.toml
Normal file
6
podcasts-data/diesel.toml
Normal file
@ -0,0 +1,6 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
patch_file = "src/schema.patch"
|
||||
@ -0,0 +1,53 @@
|
||||
ALTER TABLE episode RENAME TO old_table;
|
||||
|
||||
CREATE TABLE episode (
|
||||
title TEXT NOT NULL,
|
||||
uri TEXT,
|
||||
local_uri TEXT,
|
||||
description TEXT,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
length INTEGER,
|
||||
duration INTEGER,
|
||||
guid TEXT,
|
||||
played INTEGER,
|
||||
podcast_id INTEGER NOT NULL,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
archive INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (title, podcast_id)
|
||||
);
|
||||
|
||||
INSERT INTO episode (title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, favorite, archive)
|
||||
SELECT title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, 0, 0
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
|
||||
ALTER TABLE podcast RENAME TO old_table;
|
||||
CREATE TABLE `podcast` (
|
||||
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
|
||||
`title` TEXT NOT NULL,
|
||||
`link` TEXT NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`image_uri` TEXT,
|
||||
`source_id` INTEGER NOT NULL UNIQUE,
|
||||
`favorite` INTEGER NOT NULL DEFAULT 0,
|
||||
`archive` INTEGER NOT NULL DEFAULT 0,
|
||||
`always_dl` INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO podcast (
|
||||
id,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
image_uri,
|
||||
source_id
|
||||
) SELECT id,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
image_uri,
|
||||
source_id
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
@ -0,0 +1,66 @@
|
||||
ALTER TABLE episode RENAME TO old_table;
|
||||
|
||||
CREATE TABLE episode (
|
||||
title TEXT NOT NULL,
|
||||
uri TEXT,
|
||||
local_uri TEXT,
|
||||
description TEXT,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
length INTEGER,
|
||||
duration INTEGER,
|
||||
guid TEXT,
|
||||
played INTEGER,
|
||||
podcast_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (title, podcast_id)
|
||||
);
|
||||
|
||||
INSERT INTO episode (
|
||||
title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch,
|
||||
length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
podcast_id
|
||||
) SELECT title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch, length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
podcast_id
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
|
||||
ALTER TABLE podcast RENAME TO old_table;
|
||||
CREATE TABLE `podcast` (
|
||||
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
|
||||
`title` TEXT NOT NULL,
|
||||
`link` TEXT NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`image_uri` TEXT,
|
||||
`source_id` INTEGER NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
INSERT INTO podcast (
|
||||
id,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
image_uri,
|
||||
source_id
|
||||
) SELECT id,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
image_uri,
|
||||
source_id
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
@ -0,0 +1,40 @@
|
||||
ALTER TABLE episodes RENAME TO old_table;
|
||||
ALTER TABLE shows RENAME TO podcast;
|
||||
|
||||
CREATE TABLE episode (
|
||||
title TEXT NOT NULL,
|
||||
uri TEXT,
|
||||
local_uri TEXT,
|
||||
description TEXT,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
length INTEGER,
|
||||
duration INTEGER,
|
||||
guid TEXT,
|
||||
played INTEGER,
|
||||
podcast_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (title, podcast_id)
|
||||
);
|
||||
|
||||
INSERT INTO episode (
|
||||
title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch,
|
||||
length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
podcast_id
|
||||
) SELECT title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch, length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
show_id
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
@ -0,0 +1,40 @@
|
||||
ALTER TABLE episode RENAME TO old_table;
|
||||
ALTER TABLE podcast RENAME TO shows;
|
||||
|
||||
CREATE TABLE episodes (
|
||||
title TEXT NOT NULL,
|
||||
uri TEXT,
|
||||
local_uri TEXT,
|
||||
description TEXT,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
length INTEGER,
|
||||
duration INTEGER,
|
||||
guid TEXT,
|
||||
played INTEGER,
|
||||
show_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (title, show_id)
|
||||
);
|
||||
|
||||
INSERT INTO episodes (
|
||||
title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch,
|
||||
length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
show_id
|
||||
) SELECT title,
|
||||
uri,
|
||||
local_uri,
|
||||
description,
|
||||
epoch, length,
|
||||
duration,
|
||||
guid,
|
||||
played,
|
||||
podcast_id
|
||||
FROM old_table;
|
||||
|
||||
Drop table old_table;
|
||||
@ -1,4 +1,25 @@
|
||||
// database.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Database Setup. This is only public to help with some unit tests.
|
||||
// Diesel embed_migrations! triggers the lint
|
||||
#![allow(unused_imports)]
|
||||
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2;
|
||||
@ -7,10 +28,10 @@ use diesel::r2d2::ConnectionManager;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use errors::DataError;
|
||||
use crate::errors::DataError;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use xdg_dirs;
|
||||
use crate::xdg_dirs;
|
||||
|
||||
type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
|
||||
|
||||
@ -22,16 +43,16 @@ lazy_static! {
|
||||
|
||||
#[cfg(not(test))]
|
||||
lazy_static! {
|
||||
static ref DB_PATH: PathBuf = xdg_dirs::HAMMOND_XDG.place_data_file("hammond.db").unwrap();
|
||||
static ref DB_PATH: PathBuf = xdg_dirs::PODCASTS_XDG
|
||||
.place_data_file("podcasts.db")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
extern crate tempdir;
|
||||
|
||||
#[cfg(test)]
|
||||
lazy_static! {
|
||||
static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("hammond_unit_test").unwrap() };
|
||||
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
|
||||
pub(crate) static ref TEMPDIR: tempdir::TempDir =
|
||||
{ tempdir::TempDir::new("podcasts_unit_test").unwrap() };
|
||||
static ref DB_PATH: PathBuf = TEMPDIR.path().join("podcasts.db");
|
||||
}
|
||||
|
||||
/// Get an r2d2 `SqliteConnection`.
|
||||
@ -62,12 +83,12 @@ fn run_migration_on(connection: &SqliteConnection) -> Result<(), DataError> {
|
||||
|
||||
/// Reset the database into a clean state.
|
||||
// Test share a Temp file db.
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
pub fn truncate_db() -> Result<(), DataError> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
con.execute("DELETE FROM episode")?;
|
||||
con.execute("DELETE FROM podcast")?;
|
||||
con.execute("DELETE FROM episodes")?;
|
||||
con.execute("DELETE FROM shows")?;
|
||||
con.execute("DELETE FROM source")?;
|
||||
Ok(())
|
||||
}
|
||||
@ -1,3 +1,22 @@
|
||||
// dbqueries.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Random CRUD helper functions.
|
||||
|
||||
use chrono::prelude::*;
|
||||
@ -5,15 +24,14 @@ use diesel::prelude::*;
|
||||
|
||||
use diesel;
|
||||
use diesel::dsl::exists;
|
||||
use diesel::query_builder::AsQuery;
|
||||
use diesel::select;
|
||||
|
||||
use database::connection;
|
||||
use errors::DataError;
|
||||
use models::*;
|
||||
use crate::database::connection;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::*;
|
||||
|
||||
pub fn get_sources() -> Result<Vec<Source>, DataError> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -23,106 +41,106 @@ pub fn get_sources() -> Result<Vec<Source>, DataError> {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
pub fn get_podcasts() -> Result<Vec<Show>, DataError> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
podcast
|
||||
shows
|
||||
.order(title.asc())
|
||||
.load::<Podcast>(&con)
|
||||
.load::<Show>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Show>, DataError> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
podcast
|
||||
shows
|
||||
.order(title.asc())
|
||||
.filter(id.ne_all(filter_ids))
|
||||
.load::<Podcast>(&con)
|
||||
.load::<Show>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.order(epoch.desc())
|
||||
.load::<Episode>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.select((rowid, local_uri, played))
|
||||
.filter(local_uri.is_not_null())
|
||||
.load::<EpisodeCleanerQuery>(&con)
|
||||
.load::<EpisodeCleanerModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> {
|
||||
// use schema::episode::dsl::*;
|
||||
// use schema::episodes::dsl::*;
|
||||
|
||||
// let db = connection();
|
||||
// let con = db.get()?;
|
||||
// episode
|
||||
// episodes
|
||||
// .filter(played.is_not_null())
|
||||
// .load::<Episode>(&con)
|
||||
// .map_err(From::from)
|
||||
// }
|
||||
|
||||
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.select((rowid, local_uri, played))
|
||||
.filter(played.is_not_null())
|
||||
.load::<EpisodeCleanerQuery>(&con)
|
||||
.load::<EpisodeCleanerModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.filter(rowid.eq(ep_id))
|
||||
.get_result::<Episode>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetQuery, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetModel, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.select((
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
|
||||
))
|
||||
.filter(rowid.eq(ep_id))
|
||||
.get_result::<EpisodeWidgetQuery>(&con)
|
||||
.get_result::<EpisodeWidgetModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.filter(rowid.eq(ep_id))
|
||||
.select(local_uri)
|
||||
.get_result::<Option<String>>(&con)
|
||||
@ -132,48 +150,48 @@ pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataE
|
||||
pub fn get_episodes_widgets_filter_limit(
|
||||
filter_ids: &[i32],
|
||||
limit: u32,
|
||||
) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
) -> Result<Vec<EpisodeWidgetModel>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
let columns = (
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
|
||||
);
|
||||
|
||||
episode
|
||||
episodes
|
||||
.select(columns)
|
||||
.order(epoch.desc())
|
||||
.filter(podcast_id.ne_all(filter_ids))
|
||||
.filter(show_id.ne_all(filter_ids))
|
||||
.limit(i64::from(limit))
|
||||
.load::<EpisodeWidgetQuery>(&con)
|
||||
.load::<EpisodeWidgetModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_podcast_from_id(pid: i32) -> Result<Podcast, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
pub fn get_podcast_from_id(pid: i32) -> Result<Show, DataError> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
podcast
|
||||
shows
|
||||
.filter(id.eq(pid))
|
||||
.get_result::<Podcast>(&con)
|
||||
.get_result::<Show>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_podcast_cover_from_id(pid: i32) -> Result<PodcastCoverQuery, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
pub fn get_podcast_cover_from_id(pid: i32) -> Result<ShowCoverModel, DataError> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
podcast
|
||||
shows
|
||||
.select((id, title, image_uri))
|
||||
.filter(id.eq(pid))
|
||||
.get_result::<PodcastCoverQuery>(&con)
|
||||
.get_result::<ShowCoverModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub fn get_pd_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -183,7 +201,7 @@ pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
|
||||
pub fn get_pd_episodes_count(parent: &Show) -> Result<i64, DataError> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -193,24 +211,24 @@ pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub fn get_pd_episodeswidgets(parent: &Show) -> Result<Vec<EpisodeWidgetModel>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
let columns = (
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
|
||||
);
|
||||
|
||||
episode
|
||||
episodes
|
||||
.select(columns)
|
||||
.filter(podcast_id.eq(parent.id()))
|
||||
.filter(show_id.eq(parent.id()))
|
||||
.order(epoch.desc())
|
||||
.load::<EpisodeWidgetQuery>(&con)
|
||||
.load::<EpisodeWidgetModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub fn get_pd_unplayed_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -221,8 +239,8 @@ pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataEr
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
// pub(crate) fn get_pd_episodes_limit(parent: &Podcast, limit: u32) ->
|
||||
// Result<Vec<Episode>, DataError> { use schema::episode::dsl::*;
|
||||
// pub(crate) fn get_pd_episodes_limit(parent: &Show, limit: u32) ->
|
||||
// Result<Vec<Episode>, DataError> { use schema::episodes::dsl::*;
|
||||
|
||||
// let db = connection();
|
||||
// let con = db.get()?;
|
||||
@ -235,7 +253,7 @@ pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataEr
|
||||
// }
|
||||
|
||||
pub fn get_source_from_uri(uri_: &str) -> Result<Source, DataError> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -246,7 +264,7 @@ pub fn get_source_from_uri(uri_: &str) -> Result<Source, DataError> {
|
||||
}
|
||||
|
||||
pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -256,25 +274,25 @@ pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_podcast_from_source_id(sid: i32) -> Result<Podcast, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
pub fn get_podcast_from_source_id(sid: i32) -> Result<Show, DataError> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
podcast
|
||||
shows
|
||||
.filter(source_id.eq(sid))
|
||||
.get_result::<Podcast>(&con)
|
||||
.get_result::<Show>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
episodes
|
||||
.filter(title.eq(title_))
|
||||
.filter(podcast_id.eq(pid))
|
||||
.filter(show_id.eq(pid))
|
||||
.get_result::<Episode>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
@ -283,19 +301,36 @@ pub(crate) fn get_episode_minimal_from_pk(
|
||||
title_: &str,
|
||||
pid: i32,
|
||||
) -> Result<EpisodeMinimal, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode
|
||||
.select((rowid, title, uri, epoch, duration, guid, podcast_id))
|
||||
episodes
|
||||
.select((rowid, title, uri, epoch, length, duration, guid, show_id))
|
||||
.filter(title.eq(title_))
|
||||
.filter(podcast_id.eq(pid))
|
||||
.filter(show_id.eq(pid))
|
||||
.get_result::<EpisodeMinimal>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_feed(pd: &Podcast) -> Result<(), DataError> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn get_episode_cleaner_from_pk(
|
||||
title_: &str,
|
||||
pid: i32,
|
||||
) -> Result<EpisodeCleanerModel, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episodes
|
||||
.select((rowid, local_uri, played))
|
||||
.filter(title.eq(title_))
|
||||
.filter(show_id.eq(pid))
|
||||
.get_result::<EpisodeCleanerModel>(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_feed(pd: &Show) -> Result<(), DataError> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -309,25 +344,25 @@ pub(crate) fn remove_feed(pd: &Podcast) -> Result<(), DataError> {
|
||||
}
|
||||
|
||||
fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
|
||||
diesel::delete(source.filter(id.eq(source_id))).execute(con)
|
||||
}
|
||||
|
||||
fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
|
||||
use schema::podcast::dsl::*;
|
||||
fn delete_podcast(con: &SqliteConnection, show_id: i32) -> QueryResult<usize> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
|
||||
diesel::delete(podcast.filter(id.eq(podcast_id))).execute(con)
|
||||
diesel::delete(shows.filter(id.eq(show_id))).execute(con)
|
||||
}
|
||||
|
||||
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
|
||||
diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(con)
|
||||
diesel::delete(episodes.filter(show_id.eq(parent_id))).execute(con)
|
||||
}
|
||||
|
||||
pub fn source_exists(url: &str) -> Result<bool, DataError> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
@ -338,70 +373,84 @@ pub fn source_exists(url: &str) -> Result<bool, DataError> {
|
||||
}
|
||||
|
||||
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
use crate::schema::shows::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
select(exists(podcast.filter(source_id.eq(source_id_))))
|
||||
select(exists(shows.filter(source_id.eq(source_id_))))
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub(crate) fn episode_exists(title_: &str, show_id_: i32) -> Result<bool, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
select(exists(episode.filter(podcast_id.eq(podcast_id_)).filter(title.eq(title_))))
|
||||
select(exists(episodes.filter(show_id.eq(show_id_)).filter(title.eq(title_))))
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
/// Check if the `episode table contains any rows
|
||||
/// Check if the `episodes table contains any rows
|
||||
///
|
||||
/// Return true if `episode` table is populated.
|
||||
pub fn is_episodes_populated() -> Result<bool, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
/// Return true if `episodes` table is populated.
|
||||
pub fn is_episodes_populated(filter_show_ids: &[i32]) -> Result<bool, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
select(exists(episode.as_query()))
|
||||
select(exists(episodes.filter(show_id.ne_all(filter_show_ids))))
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
/// Check if the `podcast` table contains any rows
|
||||
/// Check if the `shows` table contains any rows
|
||||
///
|
||||
/// Return true if `podcast table is populated.
|
||||
/// Return true if `shows` table is populated.
|
||||
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
|
||||
use schema::podcast::dsl::*;
|
||||
use crate::schema::shows::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
select(exists(podcast.filter(id.ne_all(filter_ids))))
|
||||
select(exists(shows.filter(id.ne_all(filter_ids))))
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
/// Check if the `source` table contains any rows
|
||||
///
|
||||
/// Return true if `source` table is populated.
|
||||
pub fn is_source_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
|
||||
use crate::schema::source::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
select(exists(source.filter(id.ne_all(filter_ids))))
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
diesel::insert_into(episode)
|
||||
diesel::insert_into(episodes)
|
||||
.values(eps)
|
||||
.execute(&*con)
|
||||
.map_err(From::from)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
pub fn update_none_to_played_now(parent: &Show) -> Result<usize, DataError> {
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
@ -417,25 +466,27 @@ pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use database::*;
|
||||
use pipeline;
|
||||
use crate::database::*;
|
||||
use crate::pipeline;
|
||||
use failure::Error;
|
||||
|
||||
#[test]
|
||||
fn test_update_none_to_played_now() {
|
||||
truncate_db().unwrap();
|
||||
fn test_update_none_to_played_now() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill";
|
||||
let source = Source::from_url(url).unwrap();
|
||||
let source = Source::from_url(url)?;
|
||||
let id = source.id();
|
||||
pipeline::run(vec![source], true).unwrap();
|
||||
let pd = get_podcast_from_source_id(id).unwrap();
|
||||
pipeline::run(vec![source])?;
|
||||
let pd = get_podcast_from_source_id(id)?;
|
||||
|
||||
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
|
||||
let eps_num = get_pd_unplayed_episodes(&pd)?.len();
|
||||
assert_ne!(eps_num, 0);
|
||||
|
||||
update_none_to_played_now(&pd).unwrap();
|
||||
let eps_num2 = get_pd_unplayed_episodes(&pd).unwrap().len();
|
||||
update_none_to_played_now(&pd)?;
|
||||
let eps_num2 = get_pd_unplayed_episodes(&pd)?.len();
|
||||
assert_eq!(eps_num2, 0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
125
podcasts-data/src/errors.rs
Normal file
125
podcasts-data/src/errors.rs
Normal file
@ -0,0 +1,125 @@
|
||||
// errors.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use diesel;
|
||||
use diesel::r2d2;
|
||||
use diesel_migrations::RunMigrationsError;
|
||||
use http;
|
||||
use hyper;
|
||||
use native_tls;
|
||||
use rss;
|
||||
use url;
|
||||
use xml;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::models::Source;
|
||||
|
||||
#[fail(
|
||||
display = "Request to {} returned {}. Context: {}",
|
||||
url, status_code, context
|
||||
)]
|
||||
#[derive(Fail, Debug)]
|
||||
pub struct HttpStatusError {
|
||||
url: String,
|
||||
status_code: hyper::StatusCode,
|
||||
context: String,
|
||||
}
|
||||
|
||||
impl HttpStatusError {
|
||||
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
|
||||
HttpStatusError {
|
||||
url,
|
||||
status_code: code,
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Fail, Debug)]
|
||||
pub enum DataError {
|
||||
#[fail(display = "SQL Query failed: {}", _0)]
|
||||
DieselResultError(#[cause] diesel::result::Error),
|
||||
#[fail(display = "Database Migration error: {}", _0)]
|
||||
DieselMigrationError(#[cause] RunMigrationsError),
|
||||
#[fail(display = "R2D2 error: {}", _0)]
|
||||
R2D2Error(#[cause] r2d2::Error),
|
||||
#[fail(display = "R2D2 Pool error: {}", _0)]
|
||||
R2D2PoolError(#[cause] r2d2::PoolError),
|
||||
#[fail(display = "Hyper Error: {}", _0)]
|
||||
HyperError(#[cause] hyper::Error),
|
||||
#[fail(display = "ToStr Error: {}", _0)]
|
||||
HttpToStr(#[cause] http::header::ToStrError),
|
||||
#[fail(display = "Failed to parse a url: {}", _0)]
|
||||
UrlError(#[cause] url::ParseError),
|
||||
#[fail(display = "TLS Error: {}", _0)]
|
||||
TLSError(#[cause] native_tls::Error),
|
||||
#[fail(display = "IO Error: {}", _0)]
|
||||
IOError(#[cause] io::Error),
|
||||
#[fail(display = "RSS Error: {}", _0)]
|
||||
RssError(#[cause] rss::Error),
|
||||
#[fail(display = "XML Reader Error: {}", _0)]
|
||||
XmlReaderError(#[cause] xml::reader::Error),
|
||||
#[fail(display = "Error: {}", _0)]
|
||||
Bail(String),
|
||||
#[fail(display = "{}", _0)]
|
||||
HttpStatusGeneral(HttpStatusError),
|
||||
#[fail(display = "Source redirects to a new url")]
|
||||
FeedRedirect(Source),
|
||||
#[fail(display = "Feed is up to date")]
|
||||
FeedNotModified(Source),
|
||||
#[fail(
|
||||
display = "Error occurred while Parsing an Episode. Reason: {}",
|
||||
reason
|
||||
)]
|
||||
ParseEpisodeError { reason: String, parent_id: i32 },
|
||||
#[fail(display = "Episode was not changed and thus skipped.")]
|
||||
EpisodeNotChanged,
|
||||
}
|
||||
|
||||
// Maps a type to a variant of the DataError enum
|
||||
macro_rules! easy_from_impl {
|
||||
($outer_type:ty, $from:ty => $to:expr) => (
|
||||
impl From<$from> for $outer_type {
|
||||
fn from(err: $from) -> Self {
|
||||
$to(err)
|
||||
}
|
||||
}
|
||||
);
|
||||
($outer_type:ty, $from:ty => $to:expr, $($f:ty => $t:expr),+) => (
|
||||
easy_from_impl!($outer_type, $from => $to);
|
||||
easy_from_impl!($outer_type, $($f => $t),+);
|
||||
);
|
||||
}
|
||||
|
||||
easy_from_impl!(
|
||||
DataError,
|
||||
RunMigrationsError => DataError::DieselMigrationError,
|
||||
diesel::result::Error => DataError::DieselResultError,
|
||||
r2d2::Error => DataError::R2D2Error,
|
||||
r2d2::PoolError => DataError::R2D2PoolError,
|
||||
hyper::Error => DataError::HyperError,
|
||||
http::header::ToStrError => DataError::HttpToStr,
|
||||
url::ParseError => DataError::UrlError,
|
||||
native_tls::Error => DataError::TLSError,
|
||||
io::Error => DataError::IOError,
|
||||
rss::Error => DataError::RssError,
|
||||
xml::reader::Error => DataError::XmlReaderError,
|
||||
String => DataError::Bail
|
||||
);
|
||||
@ -1,4 +1,24 @@
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(unit_arg))]
|
||||
// feed.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
|
||||
#![allow(clippy::unit_arg)]
|
||||
//! Index Feeds.
|
||||
|
||||
use futures::future::*;
|
||||
@ -6,10 +26,10 @@ use futures::prelude::*;
|
||||
use futures::stream;
|
||||
use rss;
|
||||
|
||||
use dbqueries;
|
||||
use errors::DataError;
|
||||
use models::{Index, IndexState, Update};
|
||||
use models::{NewEpisode, NewEpisodeMinimal, NewPodcast, Podcast};
|
||||
use crate::dbqueries;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::{Index, IndexState, Update};
|
||||
use crate::models::{NewEpisode, NewEpisodeMinimal, NewShow, Show};
|
||||
|
||||
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
|
||||
/// that corresponds to the `Source.uri` field.
|
||||
@ -26,25 +46,22 @@ pub struct Feed {
|
||||
impl Feed {
|
||||
/// Index the contents of the RSS `Feed` into the database.
|
||||
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send {
|
||||
self.parse_podcast_async()
|
||||
ok(self.parse_podcast())
|
||||
.and_then(|pd| pd.to_podcast())
|
||||
.and_then(move |pd| self.index_channel_items(pd))
|
||||
}
|
||||
|
||||
fn parse_podcast(&self) -> NewPodcast {
|
||||
NewPodcast::new(&self.channel, self.source_id)
|
||||
fn parse_podcast(&self) -> NewShow {
|
||||
NewShow::new(&self.channel, self.source_id)
|
||||
}
|
||||
|
||||
fn parse_podcast_async(&self) -> impl Future<Item = NewPodcast, Error = DataError> + Send {
|
||||
ok(self.parse_podcast())
|
||||
}
|
||||
|
||||
fn index_channel_items(self, pd: Podcast) -> impl Future<Item = (), Error = DataError> + Send {
|
||||
fn index_channel_items(self, pd: Show) -> impl Future<Item = (), Error = DataError> + Send {
|
||||
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
|
||||
|
||||
// Parse the episodes
|
||||
let episodes = stream.filter_map(move |item| {
|
||||
glue(&item, pd.id())
|
||||
NewEpisodeMinimal::new(&item, pd.id())
|
||||
.and_then(move |ep| determine_ep_state(ep, &item))
|
||||
.map_err(|err| error!("Failed to parse an episode: {}", err))
|
||||
.ok()
|
||||
});
|
||||
@ -56,21 +73,17 @@ impl Feed {
|
||||
}
|
||||
}
|
||||
|
||||
fn glue(item: &rss::Item, id: i32) -> Result<IndexState<NewEpisode>, DataError> {
|
||||
NewEpisodeMinimal::new(item, id).and_then(move |ep| determine_ep_state(ep, item))
|
||||
}
|
||||
|
||||
fn determine_ep_state(
|
||||
ep: NewEpisodeMinimal,
|
||||
item: &rss::Item,
|
||||
) -> Result<IndexState<NewEpisode>, DataError> {
|
||||
// Check if feed exists
|
||||
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
|
||||
let exists = dbqueries::episode_exists(ep.title(), ep.show_id())?;
|
||||
|
||||
if !exists {
|
||||
Ok(IndexState::Index(ep.into_new_episode(item)))
|
||||
} else {
|
||||
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
|
||||
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.show_id())?;
|
||||
let rowid = old.rowid();
|
||||
|
||||
if ep != old {
|
||||
@ -87,21 +100,22 @@ fn filter_episodes<'a, S>(
|
||||
where
|
||||
S: Stream<Item = IndexState<NewEpisode>, Error = DataError> + Send + 'a,
|
||||
{
|
||||
stream.filter_map(|state| match state {
|
||||
IndexState::NotChanged => None,
|
||||
// Update individual rows, and filter them
|
||||
IndexState::Update((ref ep, rowid)) => {
|
||||
ep.update(rowid)
|
||||
.map_err(|err| error!("{}", err))
|
||||
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
|
||||
.ok();
|
||||
stream
|
||||
.filter_map(|state| match state {
|
||||
IndexState::NotChanged => None,
|
||||
// Update individual rows, and filter them
|
||||
IndexState::Update((ref ep, rowid)) => {
|
||||
ep.update(rowid)
|
||||
.map_err(|err| error!("{}", err))
|
||||
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
|
||||
.ok();
|
||||
|
||||
None
|
||||
},
|
||||
IndexState::Index(s) => Some(s),
|
||||
})
|
||||
// only Index is left, collect them for batch index
|
||||
.collect()
|
||||
None
|
||||
}
|
||||
IndexState::Index(s) => Some(s),
|
||||
})
|
||||
// only Index is left, collect them for batch index
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn batch_insert_episodes(episodes: &[NewEpisode]) {
|
||||
@ -127,13 +141,14 @@ fn batch_insert_episodes(episodes: &[NewEpisode]) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use failure::Error;
|
||||
use rss::Channel;
|
||||
use tokio_core::reactor::Core;
|
||||
use tokio::{self, prelude::*};
|
||||
|
||||
use database::truncate_db;
|
||||
use dbqueries;
|
||||
use utils::get_feed;
|
||||
use Source;
|
||||
use crate::database::truncate_db;
|
||||
use crate::dbqueries;
|
||||
use crate::utils::get_feed;
|
||||
use crate::Source;
|
||||
|
||||
use std::fs;
|
||||
use std::io::BufReader;
|
||||
@ -171,10 +186,11 @@ mod tests {
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_complete_index() {
|
||||
truncate_db().unwrap();
|
||||
fn test_complete_index() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let feeds: Vec<_> = URLS.iter()
|
||||
let feeds: Vec<_> = URLS
|
||||
.iter()
|
||||
.map(|&(path, url)| {
|
||||
// Create and insert a Source into db
|
||||
let s = Source::from_url(url).unwrap();
|
||||
@ -182,41 +198,43 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut core = Core::new().unwrap();
|
||||
// Index the channels
|
||||
let list: Vec<_> = feeds.into_iter().map(|x| x.index()).collect();
|
||||
let _foo = core.run(join_all(list));
|
||||
let stream_ = stream::iter_ok(feeds).for_each(|x| x.index());
|
||||
tokio::run(stream_.map_err(|_| ()));
|
||||
|
||||
// Assert the index rows equal the controlled results
|
||||
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
|
||||
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
|
||||
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
|
||||
assert_eq!(dbqueries::get_sources()?.len(), 5);
|
||||
assert_eq!(dbqueries::get_podcasts()?.len(), 5);
|
||||
assert_eq!(dbqueries::get_episodes()?.len(), 354);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feed_parse_podcast() {
|
||||
truncate_db().unwrap();
|
||||
fn test_feed_parse_podcast() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let path = "tests/feeds/2018-01-20-Intercepted.xml";
|
||||
let feed = get_feed(path, 42);
|
||||
|
||||
let file = fs::File::open(path).unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
let file = fs::File::open(path)?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(feed.parse_podcast(), pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feed_index_channel_items() {
|
||||
truncate_db().unwrap();
|
||||
fn test_feed_index_channel_items() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let path = "tests/feeds/2018-01-20-Intercepted.xml";
|
||||
let feed = get_feed(path, 42);
|
||||
let pd = feed.parse_podcast().to_podcast().unwrap();
|
||||
let pd = feed.parse_podcast().to_podcast()?;
|
||||
|
||||
feed.index_channel_items(pd).wait().unwrap();
|
||||
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
|
||||
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
|
||||
feed.index_channel_items(pd).wait()?;
|
||||
assert_eq!(dbqueries::get_podcasts()?.len(), 1);
|
||||
assert_eq!(dbqueries::get_episodes()?.len(), 43);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
145
podcasts-data/src/lib.rs
Normal file
145
podcasts-data/src/lib.rs
Normal file
@ -0,0 +1,145 @@
|
||||
// lib.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "1024"]
|
||||
#![allow(unknown_lints)]
|
||||
#![cfg_attr(
|
||||
all(test, feature = "clippy"),
|
||||
allow(option_unwrap_used, result_unwrap_used)
|
||||
)]
|
||||
#![cfg_attr(
|
||||
feature = "clippy",
|
||||
warn(
|
||||
option_unwrap_used,
|
||||
result_unwrap_used,
|
||||
print_stdout,
|
||||
wrong_pub_self_convention,
|
||||
mut_mut,
|
||||
non_ascii_literal,
|
||||
similar_names,
|
||||
unicode_not_nfc,
|
||||
enum_glob_use,
|
||||
if_not_else,
|
||||
items_after_statements,
|
||||
used_underscore_binding
|
||||
)
|
||||
)]
|
||||
// Enable lint group collections
|
||||
#![warn(nonstandard_style, bad_style, unused)]
|
||||
#![warn(edition_2018, rust_2018_idioms)]
|
||||
// standalone lints
|
||||
#![warn(
|
||||
const_err,
|
||||
improper_ctypes,
|
||||
non_shorthand_field_patterns,
|
||||
no_mangle_generic_items,
|
||||
overflowing_literals,
|
||||
plugin_as_library,
|
||||
unconditional_recursion,
|
||||
unions_with_drop_fields,
|
||||
while_true,
|
||||
missing_debug_implementations,
|
||||
missing_docs,
|
||||
trivial_casts,
|
||||
trivial_numeric_casts,
|
||||
elided_lifetime_in_paths,
|
||||
missing_copy_implementations
|
||||
)]
|
||||
#![allow(proc_macro_derive_resolution_fallback)]
|
||||
|
||||
//! FIXME: Docs
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate maplit;
|
||||
|
||||
#[macro_use]
|
||||
extern crate derive_builder;
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel_migrations;
|
||||
// #[macro_use]
|
||||
// extern crate failure;
|
||||
#[macro_use]
|
||||
extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
pub mod database;
|
||||
#[allow(missing_docs)]
|
||||
pub mod dbqueries;
|
||||
#[allow(missing_docs)]
|
||||
pub mod errors;
|
||||
mod feed;
|
||||
pub(crate) mod models;
|
||||
pub mod opml;
|
||||
mod parser;
|
||||
pub mod pipeline;
|
||||
mod schema;
|
||||
pub mod utils;
|
||||
|
||||
pub use crate::feed::{Feed, FeedBuilder};
|
||||
pub use crate::models::Save;
|
||||
pub use crate::models::{Episode, EpisodeWidgetModel, Show, ShowCoverModel, Source};
|
||||
|
||||
// Set the user agent, See #53 for more
|
||||
// Keep this in sync with Tor-browser releases
|
||||
/// The user-agent to be used for all the requests.
|
||||
/// It originates from the Tor-browser UA.
|
||||
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0";
|
||||
|
||||
/// [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub mod xdg_dirs {
|
||||
use std::path::PathBuf;
|
||||
use xdg;
|
||||
|
||||
lazy_static! {
|
||||
pub(crate) static ref PODCASTS_XDG: xdg::BaseDirectories = {
|
||||
xdg::BaseDirectories::with_prefix("gnome-podcasts").unwrap()
|
||||
};
|
||||
|
||||
/// XDG_DATA Directory `Pathbuf`.
|
||||
pub static ref PODCASTS_DATA: PathBuf = {
|
||||
PODCASTS_XDG.create_data_directory(PODCASTS_XDG.get_data_home()).unwrap()
|
||||
};
|
||||
|
||||
/// XDG_CONFIG Directory `Pathbuf`.
|
||||
pub static ref PODCASTS_CONFIG: PathBuf = {
|
||||
PODCASTS_XDG.create_config_directory(PODCASTS_XDG.get_config_home()).unwrap()
|
||||
};
|
||||
|
||||
/// XDG_CACHE Directory `Pathbuf`.
|
||||
pub static ref PODCASTS_CACHE: PathBuf = {
|
||||
PODCASTS_XDG.create_cache_directory(PODCASTS_XDG.get_cache_home()).unwrap()
|
||||
};
|
||||
|
||||
/// GNOME Podcasts Download Directory `PathBuf`.
|
||||
pub static ref DL_DIR: PathBuf = {
|
||||
PODCASTS_XDG.create_data_directory("Downloads").unwrap()
|
||||
};
|
||||
}
|
||||
}
|
||||
19
podcasts-data/src/meson.build
Normal file
19
podcasts-data/src/meson.build
Normal file
@ -0,0 +1,19 @@
|
||||
data_sources = files(
|
||||
'models/episode.rs',
|
||||
'models/mod.rs',
|
||||
'models/new_episode.rs',
|
||||
'models/new_show.rs',
|
||||
'models/new_source.rs',
|
||||
'models/show.rs',
|
||||
'models/source.rs',
|
||||
'database.rs',
|
||||
'dbqueries.rs',
|
||||
'errors.rs',
|
||||
'feed.rs',
|
||||
'lib.rs',
|
||||
'opml.rs',
|
||||
'parser.rs',
|
||||
'pipeline.rs',
|
||||
'schema.rs',
|
||||
'utils.rs',
|
||||
)
|
||||
@ -1,18 +1,37 @@
|
||||
// episode.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use chrono::prelude::*;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use diesel::SaveChangesDsl;
|
||||
|
||||
use database::connection;
|
||||
use errors::DataError;
|
||||
use models::{Podcast, Save};
|
||||
use schema::episode;
|
||||
use crate::database::connection;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::{Save, Show};
|
||||
use crate::schema::episodes;
|
||||
|
||||
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[primary_key(title, podcast_id)]
|
||||
#[belongs_to(Podcast, foreign_key = "podcast_id")]
|
||||
#[primary_key(title, show_id)]
|
||||
#[belongs_to(Show, foreign_key = "show_id")]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model of the episode table.
|
||||
pub struct Episode {
|
||||
@ -26,9 +45,7 @@ pub struct Episode {
|
||||
duration: Option<i32>,
|
||||
guid: Option<String>,
|
||||
played: Option<i32>,
|
||||
favorite: bool,
|
||||
archive: bool,
|
||||
podcast_id: i32,
|
||||
show_id: i32,
|
||||
}
|
||||
|
||||
impl Save<Episode> for Episode {
|
||||
@ -55,11 +72,6 @@ impl Episode {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Set the `title`.
|
||||
pub fn set_title(&mut self, value: &str) {
|
||||
self.title = value.to_string();
|
||||
}
|
||||
|
||||
/// Get the value of the `uri`.
|
||||
///
|
||||
/// Represents the url(usually) that the media file will be located at.
|
||||
@ -67,11 +79,6 @@ impl Episode {
|
||||
self.uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set the `uri`.
|
||||
pub fn set_uri(&mut self, value: Option<&str>) {
|
||||
self.uri = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Get the value of the `local_uri`.
|
||||
///
|
||||
/// Represents the local uri,usually filesystem path,
|
||||
@ -80,31 +87,16 @@ impl Episode {
|
||||
self.local_uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set the `local_uri`.
|
||||
pub fn set_local_uri(&mut self, value: Option<&str>) {
|
||||
self.local_uri = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Get the `description`.
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set the `description`.
|
||||
pub fn set_description(&mut self, value: Option<&str>) {
|
||||
self.description = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Get the Episode's `guid`.
|
||||
pub fn guid(&self) -> Option<&str> {
|
||||
self.guid.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set the `guid`.
|
||||
pub fn set_guid(&mut self, value: Option<&str>) {
|
||||
self.guid = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Get the `epoch` value.
|
||||
///
|
||||
/// Retrieved from the rss Item publish date.
|
||||
@ -113,11 +105,6 @@ impl Episode {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// Set the `epoch`.
|
||||
pub fn set_epoch(&mut self, value: i32) {
|
||||
self.epoch = value;
|
||||
}
|
||||
|
||||
/// Get the `length`.
|
||||
///
|
||||
/// The number represents the size of the file in bytes.
|
||||
@ -125,11 +112,6 @@ impl Episode {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Set the `length`.
|
||||
pub fn set_length(&mut self, value: Option<i32>) {
|
||||
self.length = value;
|
||||
}
|
||||
|
||||
/// Get the `duration` value.
|
||||
///
|
||||
/// The number represents the duration of the item/episode in seconds.
|
||||
@ -137,11 +119,6 @@ impl Episode {
|
||||
self.duration
|
||||
}
|
||||
|
||||
/// Set the `duration`.
|
||||
pub fn set_duration(&mut self, value: Option<i32>) {
|
||||
self.duration = value;
|
||||
}
|
||||
|
||||
/// Epoch representation of the last time the episode was played.
|
||||
///
|
||||
/// None/Null for unplayed.
|
||||
@ -149,54 +126,19 @@ impl Episode {
|
||||
self.played
|
||||
}
|
||||
|
||||
/// Set the `played` value.
|
||||
pub fn set_played(&mut self, value: Option<i32>) {
|
||||
self.played = value;
|
||||
}
|
||||
|
||||
/// Represents the archiving policy for the episode.
|
||||
pub fn archive(&self) -> bool {
|
||||
self.archive
|
||||
}
|
||||
|
||||
/// Set the `archive` policy.
|
||||
///
|
||||
/// If true, the download cleanr will ignore the episode
|
||||
/// and the corresponding media value will never be automaticly deleted.
|
||||
pub fn set_archive(&mut self, b: bool) {
|
||||
self.archive = b
|
||||
}
|
||||
|
||||
/// Get the `favorite` status of the `Episode`.
|
||||
pub fn favorite(&self) -> bool {
|
||||
self.favorite
|
||||
}
|
||||
|
||||
/// Set `favorite` status.
|
||||
pub fn set_favorite(&mut self, b: bool) {
|
||||
self.favorite = b
|
||||
}
|
||||
|
||||
/// `Podcast` table foreign key.
|
||||
pub fn podcast_id(&self) -> i32 {
|
||||
self.podcast_id
|
||||
}
|
||||
|
||||
/// Sets the `played` value with the current `epoch` timestap and save it.
|
||||
pub fn set_played_now(&mut self) -> Result<(), DataError> {
|
||||
let epoch = Utc::now().timestamp() as i32;
|
||||
self.set_played(Some(epoch));
|
||||
self.save().map(|_| ())
|
||||
/// `Show` table foreign key.
|
||||
pub fn show_id(&self) -> i32 {
|
||||
self.show_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Queryable, AsChangeset, PartialEq)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[primary_key(title, podcast_id)]
|
||||
#[primary_key(title, show_id)]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model to be used for constructing `EpisodeWidgets`.
|
||||
pub struct EpisodeWidgetQuery {
|
||||
pub struct EpisodeWidgetModel {
|
||||
rowid: i32,
|
||||
title: String,
|
||||
uri: Option<String>,
|
||||
@ -205,14 +147,12 @@ pub struct EpisodeWidgetQuery {
|
||||
length: Option<i32>,
|
||||
duration: Option<i32>,
|
||||
played: Option<i32>,
|
||||
// favorite: bool,
|
||||
// archive: bool,
|
||||
podcast_id: i32,
|
||||
show_id: i32,
|
||||
}
|
||||
|
||||
impl From<Episode> for EpisodeWidgetQuery {
|
||||
fn from(e: Episode) -> EpisodeWidgetQuery {
|
||||
EpisodeWidgetQuery {
|
||||
impl From<Episode> for EpisodeWidgetModel {
|
||||
fn from(e: Episode) -> EpisodeWidgetModel {
|
||||
EpisodeWidgetModel {
|
||||
rowid: e.rowid,
|
||||
title: e.title,
|
||||
uri: e.uri,
|
||||
@ -221,30 +161,30 @@ impl From<Episode> for EpisodeWidgetQuery {
|
||||
length: e.length,
|
||||
duration: e.duration,
|
||||
played: e.played,
|
||||
podcast_id: e.podcast_id,
|
||||
show_id: e.show_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Save<usize> for EpisodeWidgetQuery {
|
||||
impl Save<usize> for EpisodeWidgetModel {
|
||||
type Error = DataError;
|
||||
|
||||
/// Helper method to easily save/"sync" current state of self to the
|
||||
/// Database.
|
||||
fn save(&self) -> Result<usize, Self::Error> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let tempdb = db.get()?;
|
||||
|
||||
diesel::update(episode.filter(rowid.eq(self.rowid)))
|
||||
diesel::update(episodes.filter(rowid.eq(self.rowid)))
|
||||
.set(self)
|
||||
.execute(&*tempdb)
|
||||
.map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl EpisodeWidgetQuery {
|
||||
impl EpisodeWidgetModel {
|
||||
/// Get the value of the sqlite's `ROW_ID`
|
||||
pub fn rowid(&self) -> i32 {
|
||||
self.rowid
|
||||
@ -302,11 +242,6 @@ impl EpisodeWidgetQuery {
|
||||
self.duration
|
||||
}
|
||||
|
||||
/// Set the `duration`.
|
||||
pub fn set_duration(&mut self, value: Option<i32>) {
|
||||
self.duration = value;
|
||||
}
|
||||
|
||||
/// Epoch representation of the last time the episode was played.
|
||||
///
|
||||
/// None/Null for unplayed.
|
||||
@ -315,36 +250,13 @@ impl EpisodeWidgetQuery {
|
||||
}
|
||||
|
||||
/// Set the `played` value.
|
||||
pub fn set_played(&mut self, value: Option<i32>) {
|
||||
fn set_played(&mut self, value: Option<i32>) {
|
||||
self.played = value;
|
||||
}
|
||||
|
||||
// /// Represents the archiving policy for the episode.
|
||||
// pub fn archive(&self) -> bool {
|
||||
// self.archive
|
||||
// }
|
||||
|
||||
// /// Set the `archive` policy.
|
||||
// ///
|
||||
// /// If true, the download cleanr will ignore the episode
|
||||
// /// and the corresponding media value will never be automaticly deleted.
|
||||
// pub fn set_archive(&mut self, b: bool) {
|
||||
// self.archive = b
|
||||
// }
|
||||
|
||||
// /// Get the `favorite` status of the `Episode`.
|
||||
// pub fn favorite(&self) -> bool {
|
||||
// self.favorite
|
||||
// }
|
||||
|
||||
// /// Set `favorite` status.
|
||||
// pub fn set_favorite(&mut self, b: bool) {
|
||||
// self.favorite = b
|
||||
// }
|
||||
|
||||
/// `Podcast` table foreign key.
|
||||
pub fn podcast_id(&self) -> i32 {
|
||||
self.podcast_id
|
||||
/// `Show` table foreign key.
|
||||
pub fn show_id(&self) -> i32 {
|
||||
self.show_id
|
||||
}
|
||||
|
||||
/// Sets the `played` value with the current `epoch` timestap and save it.
|
||||
@ -356,38 +268,38 @@ impl EpisodeWidgetQuery {
|
||||
}
|
||||
|
||||
#[derive(Queryable, AsChangeset, PartialEq)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[primary_key(title, podcast_id)]
|
||||
#[primary_key(title, show_id)]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model to be used internal with the `utils::checkup` function.
|
||||
pub struct EpisodeCleanerQuery {
|
||||
pub struct EpisodeCleanerModel {
|
||||
rowid: i32,
|
||||
local_uri: Option<String>,
|
||||
played: Option<i32>,
|
||||
}
|
||||
|
||||
impl Save<usize> for EpisodeCleanerQuery {
|
||||
impl Save<usize> for EpisodeCleanerModel {
|
||||
type Error = DataError;
|
||||
|
||||
/// Helper method to easily save/"sync" current state of self to the
|
||||
/// Database.
|
||||
fn save(&self) -> Result<usize, Self::Error> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
|
||||
let db = connection();
|
||||
let tempdb = db.get()?;
|
||||
|
||||
diesel::update(episode.filter(rowid.eq(self.rowid)))
|
||||
diesel::update(episodes.filter(rowid.eq(self.rowid)))
|
||||
.set(self)
|
||||
.execute(&*tempdb)
|
||||
.map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Episode> for EpisodeCleanerQuery {
|
||||
fn from(e: Episode) -> EpisodeCleanerQuery {
|
||||
EpisodeCleanerQuery {
|
||||
impl From<Episode> for EpisodeCleanerModel {
|
||||
fn from(e: Episode) -> EpisodeCleanerModel {
|
||||
EpisodeCleanerModel {
|
||||
rowid: e.rowid(),
|
||||
local_uri: e.local_uri,
|
||||
played: e.played,
|
||||
@ -395,7 +307,7 @@ impl From<Episode> for EpisodeCleanerQuery {
|
||||
}
|
||||
}
|
||||
|
||||
impl EpisodeCleanerQuery {
|
||||
impl EpisodeCleanerModel {
|
||||
/// Get the value of the sqlite's `ROW_ID`
|
||||
pub fn rowid(&self) -> i32 {
|
||||
self.rowid
|
||||
@ -428,9 +340,9 @@ impl EpisodeCleanerQuery {
|
||||
}
|
||||
|
||||
#[derive(Queryable, AsChangeset, PartialEq)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[primary_key(title, podcast_id)]
|
||||
#[primary_key(title, show_id)]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model to be used for FIXME.
|
||||
pub struct EpisodeMinimal {
|
||||
@ -438,9 +350,10 @@ pub struct EpisodeMinimal {
|
||||
title: String,
|
||||
uri: Option<String>,
|
||||
epoch: i32,
|
||||
length: Option<i32>,
|
||||
duration: Option<i32>,
|
||||
guid: Option<String>,
|
||||
podcast_id: i32,
|
||||
show_id: i32,
|
||||
}
|
||||
|
||||
impl From<Episode> for EpisodeMinimal {
|
||||
@ -449,10 +362,11 @@ impl From<Episode> for EpisodeMinimal {
|
||||
rowid: e.rowid,
|
||||
title: e.title,
|
||||
uri: e.uri,
|
||||
length: e.length,
|
||||
guid: e.guid,
|
||||
epoch: e.epoch,
|
||||
duration: e.duration,
|
||||
podcast_id: e.podcast_id,
|
||||
show_id: e.show_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -488,6 +402,13 @@ impl EpisodeMinimal {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// Get the `length`.
|
||||
///
|
||||
/// The number represents the size of the file in bytes.
|
||||
pub fn length(&self) -> Option<i32> {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Get the `duration` value.
|
||||
///
|
||||
/// The number represents the duration of the item/episode in seconds.
|
||||
@ -495,8 +416,8 @@ impl EpisodeMinimal {
|
||||
self.duration
|
||||
}
|
||||
|
||||
/// `Podcast` table foreign key.
|
||||
pub fn podcast_id(&self) -> i32 {
|
||||
self.podcast_id
|
||||
/// `Show` table foreign key.
|
||||
pub fn show_id(&self) -> i32 {
|
||||
self.show_id
|
||||
}
|
||||
}
|
||||
78
podcasts-data/src/models/mod.rs
Normal file
78
podcasts-data/src/models/mod.rs
Normal file
@ -0,0 +1,78 @@
|
||||
// mod.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
mod new_episode;
|
||||
mod new_show;
|
||||
mod new_source;
|
||||
|
||||
mod episode;
|
||||
mod show;
|
||||
mod source;
|
||||
|
||||
// use futures::prelude::*;
|
||||
// use futures::future::*;
|
||||
|
||||
pub(crate) use self::episode::EpisodeCleanerModel;
|
||||
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
|
||||
pub(crate) use self::new_show::NewShow;
|
||||
pub(crate) use self::new_source::NewSource;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::new_episode::NewEpisodeBuilder;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::new_show::NewShowBuilder;
|
||||
|
||||
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetModel};
|
||||
pub use self::show::{Show, ShowCoverModel};
|
||||
pub use self::source::Source;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum IndexState<T> {
|
||||
Index(T),
|
||||
Update((T, i32)),
|
||||
NotChanged,
|
||||
}
|
||||
|
||||
pub trait Insert<T> {
|
||||
type Error;
|
||||
|
||||
fn insert(&self) -> Result<T, Self::Error>;
|
||||
}
|
||||
|
||||
pub trait Update<T> {
|
||||
type Error;
|
||||
|
||||
fn update(&self, _: i32) -> Result<T, Self::Error>;
|
||||
}
|
||||
|
||||
// This might need to change in the future
|
||||
pub trait Index<T>: Insert<T> + Update<T> {
|
||||
type Error;
|
||||
|
||||
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
|
||||
}
|
||||
|
||||
/// FIXME: DOCS
|
||||
pub trait Save<T> {
|
||||
/// The Error type to be returned.
|
||||
type Error;
|
||||
/// Helper method to easily save/"sync" current state of a diesel model to
|
||||
/// the Database.
|
||||
fn save(&self) -> Result<T, Self::Error>;
|
||||
}
|
||||
@ -1,19 +1,38 @@
|
||||
// new_episode.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use ammonia;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use rfc822_sanitizer::parse_from_rfc2822_with_fallback as parse_rfc822;
|
||||
use rss;
|
||||
|
||||
use database::connection;
|
||||
use dbqueries;
|
||||
use errors::DataError;
|
||||
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
|
||||
use parser;
|
||||
use schema::episode;
|
||||
use utils::url_cleaner;
|
||||
use crate::database::connection;
|
||||
use crate::dbqueries;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::{Episode, EpisodeMinimal, Index, Insert, Update};
|
||||
use crate::parser;
|
||||
use crate::schema::episodes;
|
||||
use crate::utils::url_cleaner;
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[derive(Debug, Clone, Default, Builder, PartialEq)]
|
||||
#[builder(default)]
|
||||
#[builder(derive(Debug))]
|
||||
@ -26,7 +45,7 @@ pub(crate) struct NewEpisode {
|
||||
duration: Option<i32>,
|
||||
guid: Option<String>,
|
||||
epoch: i32,
|
||||
podcast_id: i32,
|
||||
show_id: i32,
|
||||
}
|
||||
|
||||
impl From<NewEpisodeMinimal> for NewEpisode {
|
||||
@ -36,7 +55,7 @@ impl From<NewEpisodeMinimal> for NewEpisode {
|
||||
.uri(e.uri)
|
||||
.duration(e.duration)
|
||||
.epoch(e.epoch)
|
||||
.podcast_id(e.podcast_id)
|
||||
.show_id(e.show_id)
|
||||
.guid(e.guid)
|
||||
.build()
|
||||
.unwrap()
|
||||
@ -47,12 +66,12 @@ impl Insert<()> for NewEpisode {
|
||||
type Error = DataError;
|
||||
|
||||
fn insert(&self) -> Result<(), DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
info!("Inserting {:?}", self.title);
|
||||
diesel::insert_into(episode)
|
||||
diesel::insert_into(episodes)
|
||||
.values(self)
|
||||
.execute(&con)
|
||||
.map_err(From::from)
|
||||
@ -64,12 +83,12 @@ impl Update<()> for NewEpisode {
|
||||
type Error = DataError;
|
||||
|
||||
fn update(&self, episode_id: i32) -> Result<(), DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
use crate::schema::episodes::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
info!("Updating {:?}", self.title);
|
||||
diesel::update(episode.filter(rowid.eq(episode_id)))
|
||||
diesel::update(episodes.filter(rowid.eq(episode_id)))
|
||||
.set(self)
|
||||
.execute(&con)
|
||||
.map_err(From::from)
|
||||
@ -83,10 +102,10 @@ impl Index<()> for NewEpisode {
|
||||
// Does not update the episode description if it's the only thing that has
|
||||
// changed.
|
||||
fn index(&self) -> Result<(), DataError> {
|
||||
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
|
||||
let exists = dbqueries::episode_exists(self.title(), self.show_id())?;
|
||||
|
||||
if exists {
|
||||
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.podcast_id())?;
|
||||
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.show_id())?;
|
||||
|
||||
if self != &other {
|
||||
self.update(other.rowid())
|
||||
@ -101,17 +120,23 @@ impl Index<()> for NewEpisode {
|
||||
|
||||
impl PartialEq<EpisodeMinimal> for NewEpisode {
|
||||
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
||||
(self.title() == other.title())
|
||||
&& (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration())
|
||||
&& (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid())
|
||||
&& (self.show_id() == other.show_id())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Episode> for NewEpisode {
|
||||
fn eq(&self, other: &Episode) -> bool {
|
||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
||||
(self.title() == other.title())
|
||||
&& (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration())
|
||||
&& (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid())
|
||||
&& (self.show_id() == other.show_id())
|
||||
&& (self.description() == other.description())
|
||||
&& (self.length() == other.length())
|
||||
}
|
||||
@ -120,14 +145,14 @@ impl PartialEq<Episode> for NewEpisode {
|
||||
impl NewEpisode {
|
||||
/// Parses an `rss::Item` into a `NewEpisode` Struct.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new(item: &rss::Item, podcast_id: i32) -> Result<Self, DataError> {
|
||||
NewEpisodeMinimal::new(item, podcast_id).map(|ep| ep.into_new_episode(item))
|
||||
pub(crate) fn new(item: &rss::Item, show_id: i32) -> Result<Self, DataError> {
|
||||
NewEpisodeMinimal::new(item, show_id).map(|ep| ep.into_new_episode(item))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn to_episode(&self) -> Result<Episode, DataError> {
|
||||
self.index()?;
|
||||
dbqueries::get_episode_from_pk(&self.title, self.podcast_id).map_err(From::from)
|
||||
dbqueries::get_episode_from_pk(&self.title, self.show_id).map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,30 +186,34 @@ impl NewEpisode {
|
||||
self.length
|
||||
}
|
||||
|
||||
pub(crate) fn podcast_id(&self) -> i32 {
|
||||
self.podcast_id
|
||||
pub(crate) fn show_id(&self) -> i32 {
|
||||
self.show_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[table_name = "episode"]
|
||||
#[table_name = "episodes"]
|
||||
#[derive(Debug, Clone, Builder, PartialEq)]
|
||||
#[builder(derive(Debug))]
|
||||
#[builder(setter(into))]
|
||||
pub(crate) struct NewEpisodeMinimal {
|
||||
title: String,
|
||||
uri: Option<String>,
|
||||
length: Option<i32>,
|
||||
duration: Option<i32>,
|
||||
epoch: i32,
|
||||
guid: Option<String>,
|
||||
podcast_id: i32,
|
||||
show_id: i32,
|
||||
}
|
||||
|
||||
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
|
||||
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
||||
(self.title() == other.title())
|
||||
&& (self.uri() == other.uri())
|
||||
&& (self.duration() == other.duration())
|
||||
&& (self.epoch() == other.epoch())
|
||||
&& (self.guid() == other.guid())
|
||||
&& (self.show_id() == other.show_id())
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,12 +231,20 @@ impl NewEpisodeMinimal {
|
||||
let title = item.title().unwrap().trim().to_owned();
|
||||
let guid = item.guid().map(|s| s.value().trim().to_owned());
|
||||
|
||||
let uri = item.enclosure()
|
||||
// Get the mime type, the `http` url and the length from the enclosure
|
||||
// http://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt
|
||||
let enc = item.enclosure();
|
||||
|
||||
// Get the url
|
||||
let uri = enc
|
||||
.map(|s| url_cleaner(s.url().trim()))
|
||||
// Fallback to Rss.Item.link if enclosure is None.
|
||||
.or_else(|| item.link().map(|s| url_cleaner(s.trim())));
|
||||
|
||||
// If url is still None return an Error as this behaviour is
|
||||
// Get the size of the content, it should be in bytes
|
||||
let length = enc.and_then(|x| x.length().parse().ok());
|
||||
|
||||
// If url is still None return an Error as this behaviour is not
|
||||
// compliant with the RSS Spec.
|
||||
if uri.is_none() {
|
||||
let err = DataError::ParseEpisodeError {
|
||||
@ -218,7 +255,7 @@ impl NewEpisodeMinimal {
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
// Default to rfc2822 represantation of epoch 0.
|
||||
// Default to rfc2822 representation of epoch 0.
|
||||
let date = parse_rfc822(item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"));
|
||||
// Should treat information from the rss feeds as invalid by default.
|
||||
// Case: "Thu, 05 Aug 2016 06:00:00 -0400" <-- Actually that was friday.
|
||||
@ -229,17 +266,18 @@ impl NewEpisodeMinimal {
|
||||
NewEpisodeMinimalBuilder::default()
|
||||
.title(title)
|
||||
.uri(uri)
|
||||
.length(length)
|
||||
.duration(duration)
|
||||
.epoch(epoch)
|
||||
.guid(guid)
|
||||
.podcast_id(parent_id)
|
||||
.show_id(parent_id)
|
||||
.build()
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
// TODO: TryInto is stabilizing in rustc v1.26!
|
||||
// ^ Jokes on you past self!
|
||||
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
|
||||
let length = item.enclosure().and_then(|x| x.length().parse().ok());
|
||||
let description = item.description().and_then(|s| {
|
||||
let sanitized_html = ammonia::Builder::new()
|
||||
// Remove `rel` attributes from `<a>` tags
|
||||
@ -254,9 +292,9 @@ impl NewEpisodeMinimal {
|
||||
.uri(self.uri)
|
||||
.duration(self.duration)
|
||||
.epoch(self.epoch)
|
||||
.podcast_id(self.podcast_id)
|
||||
.show_id(self.show_id)
|
||||
.guid(self.guid)
|
||||
.length(length)
|
||||
.length(self.length)
|
||||
.description(description)
|
||||
.build()
|
||||
.unwrap()
|
||||
@ -285,16 +323,18 @@ impl NewEpisodeMinimal {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
pub(crate) fn podcast_id(&self) -> i32 {
|
||||
self.podcast_id
|
||||
pub(crate) fn show_id(&self) -> i32 {
|
||||
self.show_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use database::truncate_db;
|
||||
use dbqueries;
|
||||
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
|
||||
use models::*;
|
||||
use crate::database::truncate_db;
|
||||
use crate::dbqueries;
|
||||
use crate::models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
|
||||
use crate::models::*;
|
||||
use failure::Error;
|
||||
|
||||
use rss::Channel;
|
||||
|
||||
@ -302,7 +342,7 @@ mod tests {
|
||||
use std::io::BufReader;
|
||||
|
||||
// TODO: Add tests for other feeds too.
|
||||
// Especially if you find an *intresting* generated feed.
|
||||
// Especially if you find an *interesting* generated feed.
|
||||
|
||||
// Known prebuilt expected objects.
|
||||
lazy_static! {
|
||||
@ -314,8 +354,9 @@ mod tests {
|
||||
)))
|
||||
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
|
||||
.epoch(1505296800)
|
||||
.length(Some(66738886))
|
||||
.duration(Some(4171))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -327,8 +368,9 @@ mod tests {
|
||||
)))
|
||||
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
|
||||
.epoch(1502272800)
|
||||
.length(Some(67527575))
|
||||
.duration(Some(4415))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -349,7 +391,7 @@ mod tests {
|
||||
.length(Some(66738886))
|
||||
.epoch(1505296800)
|
||||
.duration(Some(4171))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -373,7 +415,7 @@ mod tests {
|
||||
.length(Some(67527575))
|
||||
.epoch(1502272800)
|
||||
.duration(Some(4415))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -388,7 +430,7 @@ mod tests {
|
||||
.length(Some(66738886))
|
||||
.epoch(1505296800)
|
||||
.duration(Some(424242))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -399,9 +441,10 @@ mod tests {
|
||||
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
|
||||
)))
|
||||
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
|
||||
.length(Some(46479789))
|
||||
.epoch(1505280282)
|
||||
.duration(Some(5733))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -413,8 +456,9 @@ mod tests {
|
||||
)))
|
||||
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
|
||||
.epoch(1504670247)
|
||||
.length(Some(36544272))
|
||||
.duration(Some(4491))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -434,7 +478,7 @@ mod tests {
|
||||
.length(Some(46479789))
|
||||
.epoch(1505280282)
|
||||
.duration(Some(5733))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
@ -456,80 +500,88 @@ mod tests {
|
||||
.length(Some(36544272))
|
||||
.epoch(1504670247)
|
||||
.duration(Some(4491))
|
||||
.podcast_id(42)
|
||||
.show_id(42)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_minimal_intercepted() {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_episode_minimal_intercepted() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let episode = channel.items().iter().nth(14).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_1);
|
||||
|
||||
let episode = channel.items().iter().nth(15).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_intercepted() {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_episode_intercepted() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let episode = channel.items().iter().nth(14).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
|
||||
|
||||
let episode = channel.items().iter().nth(15).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42)?;
|
||||
|
||||
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_minimal_lup() {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_episode_minimal_lup() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let episode = channel.items().iter().nth(18).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_1);
|
||||
|
||||
let episode = channel.items().iter().nth(19).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisodeMinimal::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_lup() {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_episode_lup() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let episode = channel.items().iter().nth(18).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_LUP_1);
|
||||
|
||||
let episode = channel.items().iter().nth(19).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
let ep = NewEpisode::new(&episode, 42)?;
|
||||
assert_eq!(ep, *EXPECTED_LUP_2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_into_new_episode() {
|
||||
truncate_db().unwrap();
|
||||
fn test_minimal_into_new_episode() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let item = channel.items().iter().nth(14).unwrap();
|
||||
let ep = EXPECTED_MINIMAL_INTERCEPTED_1
|
||||
.clone()
|
||||
.into_new_episode(&item);
|
||||
println!(
|
||||
"EPISODE: {:#?}\nEXPECTED: {:#?}",
|
||||
ep, *EXPECTED_INTERCEPTED_1
|
||||
);
|
||||
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
|
||||
|
||||
let item = channel.items().iter().nth(15).unwrap();
|
||||
@ -537,61 +589,58 @@ mod tests {
|
||||
.clone()
|
||||
.into_new_episode(&item);
|
||||
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_insert() {
|
||||
truncate_db().unwrap();
|
||||
fn test_new_episode_insert() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let episode = channel.items().iter().nth(14).unwrap();
|
||||
let new_ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
new_ep.insert().unwrap();
|
||||
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
|
||||
let new_ep = NewEpisode::new(&episode, 42)?;
|
||||
new_ep.insert()?;
|
||||
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?;
|
||||
|
||||
assert_eq!(new_ep, ep);
|
||||
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1);
|
||||
assert_eq!(&*EXPECTED_INTERCEPTED_1, &ep);
|
||||
|
||||
let episode = channel.items().iter().nth(15).unwrap();
|
||||
let new_ep = NewEpisode::new(&episode, 42).unwrap();
|
||||
new_ep.insert().unwrap();
|
||||
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
|
||||
let new_ep = NewEpisode::new(&episode, 42)?;
|
||||
new_ep.insert()?;
|
||||
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?;
|
||||
|
||||
assert_eq!(new_ep, ep);
|
||||
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2);
|
||||
assert_eq!(&*EXPECTED_INTERCEPTED_2, &ep);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_update() {
|
||||
truncate_db().unwrap();
|
||||
let old = EXPECTED_INTERCEPTED_1.clone().to_episode().unwrap();
|
||||
fn test_new_episode_update() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
let old = EXPECTED_INTERCEPTED_1.clone().to_episode()?;
|
||||
|
||||
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
|
||||
updated.update(old.rowid()).unwrap();
|
||||
let mut new = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
|
||||
updated.update(old.rowid())?;
|
||||
let new = dbqueries::get_episode_from_pk(old.title(), old.show_id())?;
|
||||
|
||||
// Assert that updating does not change the rowid and podcast_id
|
||||
// Assert that updating does not change the rowid and show_id
|
||||
assert_ne!(old, new);
|
||||
assert_eq!(old.rowid(), new.rowid());
|
||||
assert_eq!(old.podcast_id(), new.podcast_id());
|
||||
assert_eq!(old.show_id(), new.show_id());
|
||||
|
||||
assert_eq!(updated, &new);
|
||||
assert_ne!(updated, &old);
|
||||
|
||||
new.set_archive(true);
|
||||
new.save().unwrap();
|
||||
|
||||
let new2 = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
|
||||
assert_eq!(true, new2.archive());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_index() {
|
||||
truncate_db().unwrap();
|
||||
fn test_new_episode_index() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
let expected = &*EXPECTED_INTERCEPTED_1;
|
||||
|
||||
// First insert
|
||||
@ -599,7 +648,7 @@ mod tests {
|
||||
// Second identical, This should take the early return path
|
||||
assert!(expected.index().is_ok());
|
||||
// Get the episode
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
|
||||
// Assert that NewPodcast is equal to the Indexed one
|
||||
assert_eq!(*expected, old);
|
||||
|
||||
@ -608,42 +657,33 @@ mod tests {
|
||||
// Update the podcast
|
||||
assert!(updated.index().is_ok());
|
||||
// Get the new Podcast
|
||||
let new = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
|
||||
let new = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
|
||||
// Assert it's diff from the old one.
|
||||
assert_ne!(new, old);
|
||||
assert_eq!(*updated, new);
|
||||
assert_eq!(new.rowid(), old.rowid());
|
||||
assert_eq!(new.podcast_id(), old.podcast_id());
|
||||
assert_eq!(new.show_id(), old.show_id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_episode_to_episode() {
|
||||
fn test_new_episode_to_episode() -> Result<(), Error> {
|
||||
let expected = &*EXPECTED_INTERCEPTED_1;
|
||||
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
|
||||
|
||||
// Assert insert() produces the same result that you would get with to_podcast()
|
||||
truncate_db().unwrap();
|
||||
expected.insert().unwrap();
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
|
||||
let ep = expected.to_episode().unwrap();
|
||||
truncate_db()?;
|
||||
expected.insert()?;
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
|
||||
let ep = expected.to_episode()?;
|
||||
assert_eq!(old, ep);
|
||||
|
||||
// Same as above, diff order
|
||||
truncate_db().unwrap();
|
||||
let ep = expected.to_episode().unwrap();
|
||||
truncate_db()?;
|
||||
let ep = expected.to_episode()?;
|
||||
// This should error as a unique constrain violation
|
||||
assert!(expected.insert().is_err());
|
||||
let mut old =
|
||||
dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
|
||||
assert_eq!(old, ep);
|
||||
|
||||
old.set_archive(true);
|
||||
old.save().unwrap();
|
||||
|
||||
// Assert that it does not mess with user preferences
|
||||
let ep = updated.to_episode().unwrap();
|
||||
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
|
||||
assert_eq!(old, ep);
|
||||
assert_eq!(old.archive(), true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,43 @@
|
||||
// new_show.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use ammonia;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use rss;
|
||||
|
||||
use errors::DataError;
|
||||
use models::Podcast;
|
||||
use models::{Index, Insert, Update};
|
||||
use schema::podcast;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::Show;
|
||||
use crate::models::{Index, Insert, Update};
|
||||
use crate::schema::shows;
|
||||
|
||||
use database::connection;
|
||||
use dbqueries;
|
||||
use utils::url_cleaner;
|
||||
use crate::database::connection;
|
||||
use crate::dbqueries;
|
||||
use crate::utils::url_cleaner;
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[table_name = "podcast"]
|
||||
#[table_name = "shows"]
|
||||
#[derive(Debug, Clone, Default, Builder, PartialEq)]
|
||||
#[builder(default)]
|
||||
#[builder(derive(Debug))]
|
||||
#[builder(setter(into))]
|
||||
pub(crate) struct NewPodcast {
|
||||
pub(crate) struct NewShow {
|
||||
title: String,
|
||||
link: String,
|
||||
description: String,
|
||||
@ -26,15 +45,15 @@ pub(crate) struct NewPodcast {
|
||||
source_id: i32,
|
||||
}
|
||||
|
||||
impl Insert<()> for NewPodcast {
|
||||
impl Insert<()> for NewShow {
|
||||
type Error = DataError;
|
||||
|
||||
fn insert(&self) -> Result<(), Self::Error> {
|
||||
use schema::podcast::dsl::*;
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
diesel::insert_into(podcast)
|
||||
diesel::insert_into(shows)
|
||||
.values(self)
|
||||
.execute(&con)
|
||||
.map(|_| ())
|
||||
@ -42,16 +61,16 @@ impl Insert<()> for NewPodcast {
|
||||
}
|
||||
}
|
||||
|
||||
impl Update<()> for NewPodcast {
|
||||
impl Update<()> for NewShow {
|
||||
type Error = DataError;
|
||||
|
||||
fn update(&self, podcast_id: i32) -> Result<(), Self::Error> {
|
||||
use schema::podcast::dsl::*;
|
||||
fn update(&self, show_id: i32) -> Result<(), Self::Error> {
|
||||
use crate::schema::shows::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
info!("Updating {}", self.title);
|
||||
diesel::update(podcast.filter(id.eq(podcast_id)))
|
||||
diesel::update(shows.filter(id.eq(show_id)))
|
||||
.set(self)
|
||||
.execute(&con)
|
||||
.map(|_| ())
|
||||
@ -61,7 +80,7 @@ impl Update<()> for NewPodcast {
|
||||
|
||||
// TODO: Maybe return an Enum<Action(Resut)> Instead.
|
||||
// It would make unti testing better too.
|
||||
impl Index<()> for NewPodcast {
|
||||
impl Index<()> for NewShow {
|
||||
type Error = DataError;
|
||||
|
||||
fn index(&self) -> Result<(), DataError> {
|
||||
@ -81,18 +100,19 @@ impl Index<()> for NewPodcast {
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Podcast> for NewPodcast {
|
||||
fn eq(&self, other: &Podcast) -> bool {
|
||||
(self.link() == other.link()) && (self.title() == other.title())
|
||||
impl PartialEq<Show> for NewShow {
|
||||
fn eq(&self, other: &Show) -> bool {
|
||||
(self.link() == other.link())
|
||||
&& (self.title() == other.title())
|
||||
&& (self.image_uri() == other.image_uri())
|
||||
&& (self.description() == other.description())
|
||||
&& (self.source_id() == other.source_id())
|
||||
}
|
||||
}
|
||||
|
||||
impl NewPodcast {
|
||||
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
|
||||
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
|
||||
impl NewShow {
|
||||
/// Parses a `rss::Channel` into a `NewShow` Struct.
|
||||
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewShow {
|
||||
let title = chan.title().trim();
|
||||
let link = url_cleaner(chan.link().trim());
|
||||
|
||||
@ -103,13 +123,14 @@ impl NewPodcast {
|
||||
.to_string();
|
||||
|
||||
// Try to get the itunes img first
|
||||
let itunes_img = chan.itunes_ext()
|
||||
let itunes_img = chan
|
||||
.itunes_ext()
|
||||
.and_then(|s| s.image().map(|url| url.trim()))
|
||||
.map(|s| s.to_owned());
|
||||
// If itunes is None, try to get the channel.image from the rss spec
|
||||
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned()));
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.link(link)
|
||||
@ -120,14 +141,14 @@ impl NewPodcast {
|
||||
}
|
||||
|
||||
// Look out for when tryinto lands into stable.
|
||||
pub(crate) fn to_podcast(&self) -> Result<Podcast, DataError> {
|
||||
pub(crate) fn to_podcast(&self) -> Result<Show, DataError> {
|
||||
self.index()?;
|
||||
dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore the following geters. They are used in unit tests mainly.
|
||||
impl NewPodcast {
|
||||
impl NewShow {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn source_id(&self) -> i32 {
|
||||
self.source_id
|
||||
@ -155,17 +176,18 @@ mod tests {
|
||||
use super::*;
|
||||
// use tokio_core::reactor::Core;
|
||||
|
||||
use failure::Error;
|
||||
use rss::Channel;
|
||||
|
||||
use database::truncate_db;
|
||||
use models::{NewPodcastBuilder, Save};
|
||||
use crate::database::truncate_db;
|
||||
use crate::models::NewShowBuilder;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
|
||||
// Pre-built expected NewPodcast structs.
|
||||
// Pre-built expected NewShow structs.
|
||||
lazy_static! {
|
||||
static ref EXPECTED_INTERCEPTED: NewPodcast = {
|
||||
static ref EXPECTED_INTERCEPTED: NewShow = {
|
||||
let descr = "The people behind The Intercept’s fearless reporting and incisive \
|
||||
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \
|
||||
others—discuss the crucial issues of our time: national security, civil \
|
||||
@ -173,7 +195,7 @@ mod tests {
|
||||
artists, thinkers, and newsmakers who challenge our preconceptions about \
|
||||
the world we live in.";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title("Intercepted with Jeremy Scahill")
|
||||
.link("https://theintercept.com/podcasts")
|
||||
.description(descr)
|
||||
@ -186,12 +208,12 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_LUP: NewPodcast = {
|
||||
static ref EXPECTED_LUP: NewShow = {
|
||||
let descr = "An open show powered by community LINUX Unplugged takes the best \
|
||||
attributes of open collaboration and focuses them into a weekly \
|
||||
lifestyle show about Linux.";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title("LINUX Unplugged Podcast")
|
||||
.link("http://www.jupiterbroadcasting.com/")
|
||||
.description(descr)
|
||||
@ -202,7 +224,7 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_TIPOFF: NewPodcast = {
|
||||
static ref EXPECTED_TIPOFF: NewShow = {
|
||||
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
|
||||
scenes of some of the best investigative journalism from recent years. \
|
||||
Each episode we’ll be digging into an investigative scoop- hearing from \
|
||||
@ -213,7 +235,7 @@ mod tests {
|
||||
complicated detective work that goes into doing great investigative \
|
||||
journalism- then this is the podcast for you.</p>";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title("The Tip Off")
|
||||
.link("http://www.acast.com/thetipoff")
|
||||
.description(desc)
|
||||
@ -225,7 +247,7 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_STARS: NewPodcast = {
|
||||
static ref EXPECTED_STARS: NewShow = {
|
||||
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars \
|
||||
is a gripping noir science fiction thriller in 14 episodes: Forbidden \
|
||||
love, a crashed UFO, an alien body, and an impossible heist unlike any \
|
||||
@ -235,7 +257,7 @@ mod tests {
|
||||
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
|
||||
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title("Steal the Stars")
|
||||
.link("http://tor-labs.com/")
|
||||
.description(descr)
|
||||
@ -244,12 +266,12 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_CODE: NewPodcast = {
|
||||
static ref EXPECTED_CODE: NewShow = {
|
||||
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \
|
||||
David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \
|
||||
Livingston-Gray. Brought to you by @therubyrep.";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
NewShowBuilder::default()
|
||||
.title("Greater Than Code")
|
||||
.link("https://www.greaterthancode.com/")
|
||||
.description(descr)
|
||||
@ -260,8 +282,8 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
|
||||
NewPodcastBuilder::default()
|
||||
static ref EXPECTED_ELLINOFRENEIA: NewShow = {
|
||||
NewShowBuilder::default()
|
||||
.title("Ελληνοφρένεια")
|
||||
.link("https://ellinofreneia.sealabs.net/feed.rss")
|
||||
.description("Ανεπίσημο feed της Ελληνοφρένειας")
|
||||
@ -270,8 +292,8 @@ mod tests {
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
|
||||
NewPodcastBuilder::default()
|
||||
static ref UPDATED_DESC_INTERCEPTED: NewShow = {
|
||||
NewShowBuilder::default()
|
||||
.title("Intercepted with Jeremy Scahill")
|
||||
.link("https://theintercept.com/podcasts")
|
||||
.description("New Description")
|
||||
@ -287,73 +309,80 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_intercepted() {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_intercepted() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_INTERCEPTED, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_lup() {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_lup() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_LUP, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_thetipoff() {
|
||||
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_thetipoff() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_TIPOFF, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_steal_the_stars() {
|
||||
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_steal_the_stars() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_STARS, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_greater_than_code() {
|
||||
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_greater_than_code() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_CODE, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_ellinofreneia() {
|
||||
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_ellinofreneia() -> Result<(), Error> {
|
||||
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let pd = NewPodcast::new(&channel, 42);
|
||||
let pd = NewShow::new(&channel, 42);
|
||||
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
// This maybe could be a doc test on insert.
|
||||
fn test_new_podcast_insert() {
|
||||
truncate_db().unwrap();
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
|
||||
let channel = Channel::read_from(BufReader::new(file)).unwrap();
|
||||
fn test_new_podcast_insert() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
|
||||
let channel = Channel::read_from(BufReader::new(file))?;
|
||||
|
||||
let npd = NewPodcast::new(&channel, 42);
|
||||
npd.insert().unwrap();
|
||||
let pd = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
let npd = NewShow::new(&channel, 42);
|
||||
npd.insert()?;
|
||||
let pd = dbqueries::get_podcast_from_source_id(42)?;
|
||||
|
||||
assert_eq!(npd, pd);
|
||||
assert_eq!(*EXPECTED_INTERCEPTED, npd);
|
||||
assert_eq!(&*EXPECTED_INTERCEPTED, &pd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -361,77 +390,64 @@ mod tests {
|
||||
// Currently there's a test that only checks new description or title.
|
||||
// If you have time and want to help, implement the test for the other fields
|
||||
// too.
|
||||
fn test_new_podcast_update() {
|
||||
truncate_db().unwrap();
|
||||
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
|
||||
fn test_new_podcast_update() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
let old = EXPECTED_INTERCEPTED.to_podcast()?;
|
||||
|
||||
let updated = &*UPDATED_DESC_INTERCEPTED;
|
||||
updated.update(old.id()).unwrap();
|
||||
let mut new = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
updated.update(old.id())?;
|
||||
let new = dbqueries::get_podcast_from_source_id(42)?;
|
||||
|
||||
assert_ne!(old, new);
|
||||
assert_eq!(old.id(), new.id());
|
||||
assert_eq!(old.source_id(), new.source_id());
|
||||
assert_eq!(updated, &new);
|
||||
assert_ne!(updated, &old);
|
||||
|
||||
// Chech that the update does not override user preferences.
|
||||
new.set_archive(true);
|
||||
new.save().unwrap();
|
||||
|
||||
let new2 = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
assert_eq!(true, new2.archive());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_podcast_index() {
|
||||
truncate_db().unwrap();
|
||||
fn test_new_podcast_index() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
// First insert
|
||||
assert!(EXPECTED_INTERCEPTED.index().is_ok());
|
||||
// Second identical, This should take the early return path
|
||||
assert!(EXPECTED_INTERCEPTED.index().is_ok());
|
||||
// Get the podcast
|
||||
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
// Assert that NewPodcast is equal to the Indexed one
|
||||
let old = dbqueries::get_podcast_from_source_id(42)?;
|
||||
// Assert that NewShow is equal to the Indexed one
|
||||
assert_eq!(&*EXPECTED_INTERCEPTED, &old);
|
||||
|
||||
let updated = &*UPDATED_DESC_INTERCEPTED;
|
||||
|
||||
// Update the podcast
|
||||
assert!(updated.index().is_ok());
|
||||
// Get the new Podcast
|
||||
let new = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
// Get the new Show
|
||||
let new = dbqueries::get_podcast_from_source_id(42)?;
|
||||
// Assert it's diff from the old one.
|
||||
assert_ne!(new, old);
|
||||
assert_eq!(new.id(), old.id());
|
||||
assert_eq!(new.source_id(), old.source_id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_podcast() {
|
||||
fn test_to_podcast() -> Result<(), Error> {
|
||||
// Assert insert() produces the same result that you would get with to_podcast()
|
||||
truncate_db().unwrap();
|
||||
EXPECTED_INTERCEPTED.insert().unwrap();
|
||||
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
|
||||
truncate_db()?;
|
||||
EXPECTED_INTERCEPTED.insert()?;
|
||||
let old = dbqueries::get_podcast_from_source_id(42)?;
|
||||
let pd = EXPECTED_INTERCEPTED.to_podcast()?;
|
||||
assert_eq!(old, pd);
|
||||
|
||||
// Same as above, diff order
|
||||
truncate_db().unwrap();
|
||||
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
|
||||
truncate_db()?;
|
||||
let pd = EXPECTED_INTERCEPTED.to_podcast()?;
|
||||
// This should error as a unique constrain violation
|
||||
assert!(EXPECTED_INTERCEPTED.insert().is_err());
|
||||
let mut old = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
let old = dbqueries::get_podcast_from_source_id(42)?;
|
||||
assert_eq!(old, pd);
|
||||
|
||||
old.set_archive(true);
|
||||
old.save().unwrap();
|
||||
|
||||
// Assert that it does not mess with user preferences
|
||||
let pd = UPDATED_DESC_INTERCEPTED.to_podcast().unwrap();
|
||||
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
|
||||
assert_eq!(old, pd);
|
||||
assert_eq!(old.archive(), true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,32 @@
|
||||
// new_source.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use url::Url;
|
||||
|
||||
use database::connection;
|
||||
use dbqueries;
|
||||
use crate::database::connection;
|
||||
use crate::dbqueries;
|
||||
// use models::{Insert, Update};
|
||||
use errors::DataError;
|
||||
use models::Source;
|
||||
use schema::source;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::Source;
|
||||
use crate::schema::source;
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "source"]
|
||||
@ -31,7 +50,7 @@ impl NewSource {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_or_ignore(&self) -> Result<(), DataError> {
|
||||
use schema::source::dsl::*;
|
||||
use crate::schema::source::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
110
podcasts-data/src/models/show.rs
Normal file
110
podcasts-data/src/models/show.rs
Normal file
@ -0,0 +1,110 @@
|
||||
// show.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use crate::models::Source;
|
||||
use crate::schema::shows;
|
||||
|
||||
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
|
||||
#[belongs_to(Source, foreign_key = "source_id")]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[table_name = "shows"]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model of the shows table.
|
||||
pub struct Show {
|
||||
id: i32,
|
||||
title: String,
|
||||
link: String,
|
||||
description: String,
|
||||
image_uri: Option<String>,
|
||||
source_id: i32,
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Get the Feed `id`.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get the Feed `title`.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Get the Feed `link`.
|
||||
///
|
||||
/// Usually the website/homepage of the content creator.
|
||||
pub fn link(&self) -> &str {
|
||||
&self.link
|
||||
}
|
||||
|
||||
/// Get the `description`.
|
||||
pub fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
/// Get the `image_uri`.
|
||||
///
|
||||
/// Represents the uri(url usually) that the Feed cover image is located at.
|
||||
pub fn image_uri(&self) -> Option<&str> {
|
||||
self.image_uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// `Source` table foreign key.
|
||||
pub fn source_id(&self) -> i32 {
|
||||
self.source_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Clone)]
|
||||
/// Diesel Model of the Show cover query.
|
||||
/// Used for fetching information about a Show's cover.
|
||||
pub struct ShowCoverModel {
|
||||
id: i32,
|
||||
title: String,
|
||||
image_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Show> for ShowCoverModel {
|
||||
fn from(p: Show) -> ShowCoverModel {
|
||||
ShowCoverModel {
|
||||
id: p.id(),
|
||||
title: p.title,
|
||||
image_uri: p.image_uri,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowCoverModel {
|
||||
/// Get the Feed `id`.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get the Feed `title`.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Get the `image_uri`.
|
||||
///
|
||||
/// Represents the uri(url usually) that the Feed cover image is located at.
|
||||
pub fn image_uri(&self) -> Option<&str> {
|
||||
self.image_uri.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
358
podcasts-data/src/models/source.rs
Normal file
358
podcasts-data/src/models/source.rs
Normal file
@ -0,0 +1,358 @@
|
||||
// source.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use diesel::SaveChangesDsl;
|
||||
// use failure::ResultExt;
|
||||
use rss::Channel;
|
||||
use url::Url;
|
||||
|
||||
use hyper::client::HttpConnector;
|
||||
use hyper::{Body, Client};
|
||||
use hyper_tls::HttpsConnector;
|
||||
|
||||
use http::header::{
|
||||
HeaderValue, AUTHORIZATION, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, LOCATION,
|
||||
USER_AGENT as USER_AGENT_HEADER,
|
||||
};
|
||||
use http::{Request, Response, StatusCode, Uri};
|
||||
// use futures::future::ok;
|
||||
use futures::future::{loop_fn, Future, Loop};
|
||||
use futures::prelude::*;
|
||||
|
||||
use base64::{encode_config, URL_SAFE};
|
||||
|
||||
use crate::database::connection;
|
||||
use crate::errors::*;
|
||||
use crate::feed::{Feed, FeedBuilder};
|
||||
use crate::models::{NewSource, Save};
|
||||
use crate::schema::source;
|
||||
use crate::USER_AGENT;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
|
||||
#[table_name = "source"]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
#[derive(Debug, Clone)]
|
||||
/// Diesel Model of the source table.
|
||||
pub struct Source {
|
||||
id: i32,
|
||||
uri: String,
|
||||
last_modified: Option<String>,
|
||||
http_etag: Option<String>,
|
||||
}
|
||||
|
||||
impl Save<Source> for Source {
|
||||
type Error = DataError;
|
||||
|
||||
/// Helper method to easily save/"sync" current state of self to the
|
||||
/// Database.
|
||||
fn save(&self) -> Result<Source, Self::Error> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
self.save_changes::<Source>(&*con).map_err(From::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Get the source `id` column.
|
||||
pub fn id(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Represents the location(usually url) of the Feed xml file.
|
||||
pub fn uri(&self) -> &str {
|
||||
&self.uri
|
||||
}
|
||||
|
||||
/// Set the `uri` field value.
|
||||
pub fn set_uri(&mut self, uri: String) {
|
||||
self.uri = uri;
|
||||
}
|
||||
|
||||
/// Represents the Http Last-Modified Header field.
|
||||
///
|
||||
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
|
||||
pub fn last_modified(&self) -> Option<&str> {
|
||||
self.last_modified.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set `last_modified` value.
|
||||
pub fn set_last_modified(&mut self, value: Option<String>) {
|
||||
// self.last_modified = value.map(|x| x.to_string());
|
||||
self.last_modified = value;
|
||||
}
|
||||
|
||||
/// Represents the Http Etag Header field.
|
||||
///
|
||||
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
|
||||
pub fn http_etag(&self) -> Option<&str> {
|
||||
self.http_etag.as_ref().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Set `http_etag` value.
|
||||
pub fn set_http_etag(&mut self, value: Option<&str>) {
|
||||
self.http_etag = value.map(|x| x.to_string());
|
||||
}
|
||||
|
||||
/// Extract Etag and LastModifier from res, and update self and the
|
||||
/// corresponding db row.
|
||||
fn update_etag(mut self, res: &Response<Body>) -> Result<Self, DataError> {
|
||||
let headers = res.headers();
|
||||
|
||||
let etag = headers
|
||||
.get(ETAG)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(From::from);
|
||||
let lmod = headers
|
||||
.get(LAST_MODIFIED)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(From::from);
|
||||
|
||||
if (self.http_etag() != etag) || (self.last_modified != lmod) {
|
||||
self.set_http_etag(etag);
|
||||
self.set_last_modified(lmod);
|
||||
self = self.save()?;
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Clear the `HTTP` `Etag` and `Last-modified` headers.
|
||||
/// This method does not sync the state of self in the database, call
|
||||
/// .save() method explicitly
|
||||
fn clear_etags(&mut self) {
|
||||
debug!("Source etags before clear: {:#?}", &self);
|
||||
self.http_etag = None;
|
||||
self.last_modified = None;
|
||||
}
|
||||
|
||||
fn make_err(self, context: &str, code: StatusCode) -> DataError {
|
||||
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
|
||||
}
|
||||
|
||||
// TODO match on more stuff
|
||||
// 301: Moved Permanently
|
||||
// 304: Up to date Feed, checked with the Etag
|
||||
// 307: Temporary redirect of the url
|
||||
// 308: Permanent redirect of the url
|
||||
// 401: Unathorized
|
||||
// 403: Forbidden
|
||||
// 408: Timeout
|
||||
// 410: Feed deleted
|
||||
// TODO: Rething this api,
|
||||
fn match_status(mut self, res: Response<Body>) -> Result<Response<Body>, DataError> {
|
||||
let code = res.status();
|
||||
|
||||
if code.is_success() {
|
||||
// If request is succesful save the etag
|
||||
self = self.update_etag(&res)?
|
||||
} else {
|
||||
match code.as_u16() {
|
||||
// Save etags if it returns NotModified
|
||||
304 => self = self.update_etag(&res)?,
|
||||
// Clear the Etag/lmod else
|
||||
_ => {
|
||||
self.clear_etags();
|
||||
self = self.save()?;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
match code.as_u16() {
|
||||
304 => {
|
||||
info!("304: Source, (id: {}), is up to date", self.id());
|
||||
return Err(DataError::FeedNotModified(self));
|
||||
}
|
||||
301 | 302 | 308 => {
|
||||
warn!("Feed was moved permanently.");
|
||||
self = self.update_url(&res)?;
|
||||
return Err(DataError::FeedRedirect(self));
|
||||
}
|
||||
307 => {
|
||||
warn!("307: Temporary Redirect.");
|
||||
// FIXME: How is it actually handling the redirect?
|
||||
return Err(DataError::FeedRedirect(self));
|
||||
}
|
||||
401 => return Err(self.make_err("401: Unauthorized.", code)),
|
||||
403 => return Err(self.make_err("403: Forbidden.", code)),
|
||||
404 => return Err(self.make_err("404: Not found.", code)),
|
||||
408 => return Err(self.make_err("408: Request Timeout.", code)),
|
||||
410 => return Err(self.make_err("410: Feed was deleted..", code)),
|
||||
_ => info!("HTTP StatusCode: {}", code),
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn update_url(mut self, res: &Response<Body>) -> Result<Self, DataError> {
|
||||
let code = res.status();
|
||||
let headers = res.headers();
|
||||
info!("HTTP StatusCode: {}", code);
|
||||
debug!("Headers {:#?}", headers);
|
||||
|
||||
if let Some(url) = headers.get(LOCATION) {
|
||||
debug!("Previous Source: {:#?}", &self);
|
||||
|
||||
self.set_uri(url.to_str()?.into());
|
||||
self.clear_etags();
|
||||
self = self.save()?;
|
||||
|
||||
debug!("Updated Source: {:#?}", &self);
|
||||
info!(
|
||||
"Feed url of Source {}, was updated successfully.",
|
||||
self.id()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Construct a new `Source` with the given `uri` and index it.
|
||||
///
|
||||
/// This only indexes the `Source` struct, not the Podcast Feed.
|
||||
pub fn from_url(uri: &str) -> Result<Source, DataError> {
|
||||
let url = Url::parse(uri)?;
|
||||
|
||||
NewSource::new(&url).to_source()
|
||||
}
|
||||
|
||||
/// `Feed` constructor.
|
||||
///
|
||||
/// Fetches the latest xml Feed.
|
||||
///
|
||||
/// Updates the validator Http Headers.
|
||||
///
|
||||
/// Consumes `self` and Returns the corresponding `Feed` Object.
|
||||
// Refactor into TryInto once it lands on stable.
|
||||
pub fn into_feed(
|
||||
self,
|
||||
client: Client<HttpsConnector<HttpConnector>>,
|
||||
) -> impl Future<Item = Feed, Error = DataError> {
|
||||
let id = self.id();
|
||||
let response = loop_fn(self, move |source| {
|
||||
source
|
||||
.request_constructor(&client.clone())
|
||||
.then(|res| match res {
|
||||
Ok(response) => Ok(Loop::Break(response)),
|
||||
Err(err) => match err {
|
||||
DataError::FeedRedirect(s) => {
|
||||
info!("Following redirect...");
|
||||
Ok(Loop::Continue(s))
|
||||
}
|
||||
e => Err(e),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
response
|
||||
.and_then(response_to_channel)
|
||||
.and_then(move |chan| {
|
||||
FeedBuilder::default()
|
||||
.channel(chan)
|
||||
.source_id(id)
|
||||
.build()
|
||||
.map_err(From::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn request_constructor(
|
||||
self,
|
||||
client: &Client<HttpsConnector<HttpConnector>>,
|
||||
) -> impl Future<Item = Response<Body>, Error = DataError> {
|
||||
// FIXME: remove unwrap somehow
|
||||
let uri = Uri::from_str(self.uri()).unwrap();
|
||||
let mut req = Request::get(uri).body(Body::empty()).unwrap();
|
||||
|
||||
if let Ok(url) = Url::parse(self.uri()) {
|
||||
if let Some(password) = url.password() {
|
||||
let mut auth = "Basic ".to_owned();
|
||||
auth.push_str(&encode_config(
|
||||
&format!("{}:{}", url.username(), password),
|
||||
URL_SAFE,
|
||||
));
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, HeaderValue::from_str(&auth).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Set the UserAgent cause ppl still seem to check it for some reason...
|
||||
req.headers_mut()
|
||||
.insert(USER_AGENT_HEADER, HeaderValue::from_static(USER_AGENT));
|
||||
|
||||
if let Some(etag) = self.http_etag() {
|
||||
req.headers_mut()
|
||||
.insert(IF_NONE_MATCH, HeaderValue::from_str(etag).unwrap());
|
||||
}
|
||||
|
||||
if let Some(lmod) = self.last_modified() {
|
||||
req.headers_mut()
|
||||
.insert(IF_MODIFIED_SINCE, HeaderValue::from_str(lmod).unwrap());
|
||||
}
|
||||
|
||||
client
|
||||
.request(req)
|
||||
.map_err(From::from)
|
||||
.and_then(move |res| self.match_status(res))
|
||||
}
|
||||
}
|
||||
|
||||
fn response_to_channel(
|
||||
res: Response<Body>,
|
||||
) -> impl Future<Item = Channel, Error = DataError> + Send {
|
||||
res.into_body()
|
||||
.concat2()
|
||||
.map(|x| x.into_iter())
|
||||
.map_err(From::from)
|
||||
.map(|iter| iter.collect::<Vec<u8>>())
|
||||
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
|
||||
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use failure::Error;
|
||||
use num_cpus;
|
||||
use tokio;
|
||||
|
||||
use crate::database::truncate_db;
|
||||
use crate::utils::get_feed;
|
||||
|
||||
#[test]
|
||||
fn test_into_feed() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
let mut rt = tokio::runtime::Runtime::new()?;
|
||||
let https = HttpsConnector::new(num_cpus::get())?;
|
||||
let client = Client::builder().build::<_, Body>(https);
|
||||
|
||||
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill";
|
||||
let source = Source::from_url(url)?;
|
||||
let id = source.id();
|
||||
let feed = source.into_feed(client);
|
||||
let feed = rt.block_on(feed)?;
|
||||
|
||||
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
|
||||
assert_eq!(expected, feed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
355
podcasts-data/src/opml.rs
Normal file
355
podcasts-data/src/opml.rs
Normal file
@ -0,0 +1,355 @@
|
||||
// opml.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! FIXME: Docs
|
||||
|
||||
// #![allow(unused)]
|
||||
|
||||
use crate::dbqueries;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::Source;
|
||||
use xml::{
|
||||
common::XmlVersion,
|
||||
reader,
|
||||
writer::{events::XmlEvent, EmitterConfig},
|
||||
};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use std::fs::File;
|
||||
// use std::io::BufReader;
|
||||
|
||||
use failure::Error;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
// FIXME: Make it a Diesel model
|
||||
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
|
||||
/// not `RSS` related sub-elements are omitted.
|
||||
///
|
||||
/// [spec]: http://dev.opml.org/spec2.html
|
||||
pub struct Opml {
|
||||
title: String,
|
||||
description: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Import feed url's from a `R` into the `Source` table.
|
||||
// TODO: Write test
|
||||
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
|
||||
let feeds = extract_sources(reader)?
|
||||
.iter()
|
||||
.map(|opml| Source::from_url(&opml.url))
|
||||
.filter_map(|s| {
|
||||
if let Err(ref err) = s {
|
||||
let txt = "If you think this might be a bug please consider filling a report over \
|
||||
at https://gitlab.gnome.org/World/podcasts/issues/new";
|
||||
|
||||
error!("Failed to import a Show: {}", err);
|
||||
error!("{}", txt);
|
||||
}
|
||||
|
||||
s.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(feeds)
|
||||
}
|
||||
|
||||
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
|
||||
/// return the new `Source`s
|
||||
// TODO: Write test
|
||||
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
|
||||
let content = fs::read(path)?;
|
||||
import_to_db(content.as_slice()).map_err(From::from)
|
||||
}
|
||||
|
||||
/// Export a file to `P`, taking the feeds from the database and outputting
|
||||
/// them in opml format.
|
||||
pub fn export_from_db<P: AsRef<Path>>(path: P, export_title: &str) -> Result<(), Error> {
|
||||
let file = File::create(path)?;
|
||||
export_to_file(&file, export_title)
|
||||
}
|
||||
|
||||
/// Export from `Source`s and `Show`s into `F` in OPML format
|
||||
pub fn export_to_file<F: Write>(file: F, export_title: &str) -> Result<(), Error> {
|
||||
let config = EmitterConfig::new().perform_indent(true);
|
||||
|
||||
let mut writer = config.create_writer(file);
|
||||
|
||||
let mut events: Vec<XmlEvent<'_>> = Vec::new();
|
||||
|
||||
// Set up headers
|
||||
let doc = XmlEvent::StartDocument {
|
||||
version: XmlVersion::Version10,
|
||||
encoding: Some("UTF-8"),
|
||||
standalone: Some(false),
|
||||
};
|
||||
events.push(doc);
|
||||
|
||||
let opml: XmlEvent<'_> = XmlEvent::start_element("opml")
|
||||
.attr("version", "2.0")
|
||||
.into();
|
||||
events.push(opml);
|
||||
|
||||
let head: XmlEvent<'_> = XmlEvent::start_element("head").into();
|
||||
events.push(head);
|
||||
|
||||
let title_ev: XmlEvent<'_> = XmlEvent::start_element("title").into();
|
||||
events.push(title_ev);
|
||||
|
||||
let title_chars: XmlEvent<'_> = XmlEvent::characters(export_title).into();
|
||||
events.push(title_chars);
|
||||
|
||||
// Close <title> & <head>
|
||||
events.push(XmlEvent::end_element().into());
|
||||
events.push(XmlEvent::end_element().into());
|
||||
|
||||
let body: XmlEvent<'_> = XmlEvent::start_element("body").into();
|
||||
events.push(body);
|
||||
|
||||
for event in events {
|
||||
writer.write(event)?;
|
||||
}
|
||||
|
||||
// FIXME: Make this a model of a joined query (http://docs.diesel.rs/diesel/macro.joinable.html)
|
||||
let shows = dbqueries::get_podcasts()?.into_iter().map(|show| {
|
||||
let source = dbqueries::get_source_from_id(show.source_id()).unwrap();
|
||||
(source, show)
|
||||
});
|
||||
|
||||
for (ref source, ref show) in shows {
|
||||
let title = show.title();
|
||||
let link = show.link();
|
||||
let xml_url = source.uri();
|
||||
|
||||
let s_ev: XmlEvent<'_> = XmlEvent::start_element("outline")
|
||||
.attr("text", title)
|
||||
.attr("title", title)
|
||||
.attr("type", "rss")
|
||||
.attr("xmlUrl", xml_url)
|
||||
.attr("htmlUrl", link)
|
||||
.into();
|
||||
|
||||
let end_ev: XmlEvent<'_> = XmlEvent::end_element().into();
|
||||
writer.write(s_ev)?;
|
||||
writer.write(end_ev)?;
|
||||
}
|
||||
|
||||
// Close <body> and <opml>
|
||||
let end_bod: XmlEvent<'_> = XmlEvent::end_element().into();
|
||||
writer.write(end_bod)?;
|
||||
let end_opml: XmlEvent<'_> = XmlEvent::end_element().into();
|
||||
writer.write(end_opml)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts the `outline` elements from a reader `R` and returns a `HashSet` of `Opml` structs.
|
||||
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
|
||||
let mut list = HashSet::new();
|
||||
let parser = reader::EventReader::new(reader);
|
||||
|
||||
parser
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
Ok(reader::XmlEvent::StartElement {
|
||||
name, attributes, ..
|
||||
}) => {
|
||||
if name.local_name == "outline" {
|
||||
let mut title = String::new();
|
||||
let mut url = String::new();
|
||||
let mut description = String::new();
|
||||
|
||||
attributes.into_iter().for_each(|attribute| {
|
||||
match attribute.name.local_name.as_str() {
|
||||
"title" => title = attribute.value,
|
||||
"xmlUrl" => url = attribute.value,
|
||||
"description" => description = attribute.value,
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
let feed = Opml {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
};
|
||||
list.insert(feed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
_ => Ok(()),
|
||||
})
|
||||
.collect::<Result<Vec<_>, reader::Error>>()?;
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Local;
|
||||
use failure::Error;
|
||||
use futures::Future;
|
||||
|
||||
use crate::database::{truncate_db, TEMPDIR};
|
||||
use crate::utils::get_feed;
|
||||
|
||||
const URLS: &[(&str, &str)] = {
|
||||
&[
|
||||
(
|
||||
"tests/feeds/2018-01-20-Intercepted.xml",
|
||||
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill",
|
||||
),
|
||||
(
|
||||
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
|
||||
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
|
||||
com/linuxunplugged",
|
||||
),
|
||||
(
|
||||
"tests/feeds/2018-01-20-TheTipOff.xml",
|
||||
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
|
||||
),
|
||||
(
|
||||
"tests/feeds/2018-01-20-StealTheStars.xml",
|
||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
|
||||
com/steal-the-stars",
|
||||
),
|
||||
(
|
||||
"tests/feeds/2018-01-20-GreaterThanCode.xml",
|
||||
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
|
||||
com/feed/podcast",
|
||||
),
|
||||
(
|
||||
"tests/feeds/2019-01-27-ACC.xml",
|
||||
"https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss"
|
||||
),
|
||||
]
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_extract() -> Result<(), Error> {
|
||||
let int_title = String::from("Intercepted with Jeremy Scahill");
|
||||
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
|
||||
let int_desc = String::from(
|
||||
"The people behind The Intercept’s fearless reporting and incisive \
|
||||
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss the \
|
||||
crucial issues of our time: national security, civil liberties, foreign policy, \
|
||||
and criminal justice. Plus interviews with artists, thinkers, and newsmakers \
|
||||
who challenge our preconceptions about the world we live in.",
|
||||
);
|
||||
|
||||
let dec_title = String::from("Deconstructed with Mehdi Hasan");
|
||||
let dec_url = String::from("https://rss.prod.firstlook.media/deconstructed/podcast.rss");
|
||||
let dec_desc = String::from(
|
||||
"Journalist Mehdi Hasan is known around the world for his televised takedowns of \
|
||||
presidents and prime ministers. In this new podcast from The Intercept, Mehdi \
|
||||
unpacks a game-changing news event of the week while challenging the conventional \
|
||||
wisdom. As a Brit, a Muslim and an immigrant based in Donald Trump's Washington \
|
||||
D.C., Mehdi gives a refreshingly provocative perspective on the ups and downs of \
|
||||
American—and global—politics.",
|
||||
);
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
let sample1 = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
|
||||
<opml version=\"2.0\"> \
|
||||
<head> \
|
||||
<title>Test OPML File</title> \
|
||||
<dateCreated>{}</dateCreated> \
|
||||
<docs>http://www.opml.org/spec2</docs> \
|
||||
</head> \
|
||||
<body> \
|
||||
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
|
||||
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
|
||||
</body> \
|
||||
</opml>",
|
||||
Local::now().format("%a, %d %b %Y %T %Z"),
|
||||
int_title,
|
||||
int_desc,
|
||||
int_url,
|
||||
dec_title,
|
||||
dec_desc,
|
||||
dec_url,
|
||||
);
|
||||
|
||||
let map = hashset![
|
||||
Opml {
|
||||
title: int_title,
|
||||
description: int_desc,
|
||||
url: int_url
|
||||
},
|
||||
Opml {
|
||||
title: dec_title,
|
||||
description: dec_desc,
|
||||
url: dec_url
|
||||
},
|
||||
];
|
||||
assert_eq!(extract_sources(sample1.as_bytes())?, map);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_export() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
|
||||
URLS.iter().for_each(|&(path, url)| {
|
||||
// Create and insert a Source into db
|
||||
let s = Source::from_url(url).unwrap();
|
||||
let feed = get_feed(path, s.id());
|
||||
feed.index().wait().unwrap();
|
||||
});
|
||||
|
||||
let mut map: HashSet<Opml> = HashSet::new();
|
||||
let shows = dbqueries::get_podcasts()?.into_iter().map(|show| {
|
||||
let source = dbqueries::get_source_from_id(show.source_id()).unwrap();
|
||||
(source, show)
|
||||
});
|
||||
|
||||
for (ref source, ref show) in shows {
|
||||
let title = show.title().to_string();
|
||||
// description is an optional field that we don't export
|
||||
let description = String::new();
|
||||
let url = source.uri().to_string();
|
||||
|
||||
map.insert(Opml {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
let opml_path = TEMPDIR.path().join("podcasts.opml");
|
||||
export_from_db(opml_path.as_path(), "GNOME Podcasts Subscriptions")?;
|
||||
let opml_file = File::open(opml_path.as_path())?;
|
||||
assert_eq!(extract_sources(&opml_file)?, map);
|
||||
|
||||
// extract_sources drains the reader its passed
|
||||
let mut opml_file = File::open(opml_path.as_path())?;
|
||||
let mut opml_str = String::new();
|
||||
opml_file.read_to_string(&mut opml_str)?;
|
||||
assert_eq!(opml_str, include_str!("../tests/export_test.opml"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,22 @@
|
||||
// parser.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use rss::extension::itunes::ITunesItemExtension;
|
||||
|
||||
/// Parses an Item Itunes extension and returns it's duration value in seconds.
|
||||
@ -77,5 +96,4 @@ mod tests {
|
||||
let item = Some(&extension);
|
||||
assert_eq!(parse_itunes_duration(item), Some(6970));
|
||||
}
|
||||
|
||||
}
|
||||
133
podcasts-data/src/pipeline.rs
Normal file
133
podcasts-data/src/pipeline.rs
Normal file
@ -0,0 +1,133 @@
|
||||
// pipeline.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// FIXME:
|
||||
//! Docs.
|
||||
|
||||
use futures::{future::ok, lazy, prelude::*, stream::FuturesUnordered};
|
||||
use tokio;
|
||||
|
||||
use hyper::client::HttpConnector;
|
||||
use hyper::{Body, Client};
|
||||
use hyper_tls::HttpsConnector;
|
||||
|
||||
use num_cpus;
|
||||
|
||||
use crate::errors::DataError;
|
||||
use crate::Source;
|
||||
|
||||
use std::iter::FromIterator;
|
||||
|
||||
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
|
||||
|
||||
/// The pipline to be run for indexing and updating a Podcast feed that originates from
|
||||
/// `Source.uri`.
|
||||
///
|
||||
/// Messy temp diagram:
|
||||
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
|
||||
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
|
||||
pub fn pipeline<'a, S>(sources: S, client: HttpsClient) -> impl Future<Item = (), Error = ()> + 'a
|
||||
where
|
||||
S: Stream<Item = Source, Error = DataError> + Send + 'a,
|
||||
{
|
||||
sources
|
||||
.and_then(move |s| s.into_feed(client.clone()))
|
||||
.map_err(|err| {
|
||||
match err {
|
||||
// Avoid spamming the stderr when its not an eactual error
|
||||
DataError::FeedNotModified(_) => (),
|
||||
_ => error!("Error: {}", err),
|
||||
}
|
||||
})
|
||||
.and_then(move |feed| {
|
||||
let fut = lazy(|| feed.index().map_err(|err| error!("Error: {}", err)));
|
||||
tokio::spawn(fut);
|
||||
Ok(())
|
||||
})
|
||||
// For each terminates the stream at the first error so we make sure
|
||||
// we pass good values regardless
|
||||
.then(move |_| ok(()))
|
||||
// Convert the stream into a Future to later execute as a tokio task
|
||||
.for_each(move |_| ok(()))
|
||||
}
|
||||
|
||||
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
|
||||
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
|
||||
pub fn run<S>(sources: S) -> Result<(), DataError>
|
||||
where
|
||||
S: IntoIterator<Item = Source>,
|
||||
{
|
||||
let https = HttpsConnector::new(num_cpus::get())?;
|
||||
let client = Client::builder().build::<_, Body>(https);
|
||||
|
||||
let foo = sources.into_iter().map(ok::<_, _>);
|
||||
let stream = FuturesUnordered::from_iter(foo);
|
||||
let p = pipeline(stream, client);
|
||||
tokio::run(p);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::truncate_db;
|
||||
use crate::dbqueries;
|
||||
use crate::Source;
|
||||
use failure::Error;
|
||||
|
||||
// (path, url) tuples.
|
||||
const URLS: &[&str] = &[
|
||||
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||
com/InterceptedWithJeremyScahill",
|
||||
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
|
||||
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
|
||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars",
|
||||
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
|
||||
com/feed/podcast",
|
||||
];
|
||||
|
||||
#[test]
|
||||
/// Insert feeds and update/index them.
|
||||
fn test_pipeline() -> Result<(), Error> {
|
||||
truncate_db()?;
|
||||
let bad_url = "https://gitlab.gnome.org/World/podcasts.atom";
|
||||
// if a stream returns error/None it stops
|
||||
// bad we want to parse all feeds regardless if one fails
|
||||
Source::from_url(bad_url)?;
|
||||
|
||||
URLS.iter().for_each(|url| {
|
||||
// Index the urls into the source table.
|
||||
Source::from_url(url).unwrap();
|
||||
});
|
||||
|
||||
let sources = dbqueries::get_sources()?;
|
||||
run(sources)?;
|
||||
|
||||
let sources = dbqueries::get_sources()?;
|
||||
// Run again to cover Unique constrains erros.
|
||||
run(sources)?;
|
||||
|
||||
// Assert the index rows equal the controlled results
|
||||
assert_eq!(dbqueries::get_sources()?.len(), 6);
|
||||
assert_eq!(dbqueries::get_podcasts()?.len(), 5);
|
||||
assert_eq!(dbqueries::get_episodes()?.len(), 354);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
29
podcasts-data/src/schema.patch
Normal file
29
podcasts-data/src/schema.patch
Normal file
@ -0,0 +1,29 @@
|
||||
diff --git a/podcasts-data/src/schema.rs b/podcasts-data/src/schema.rs
|
||||
index 03cbed0..88f1622 100644
|
||||
--- a/podcasts-data/src/schema.rs
|
||||
+++ b/podcasts-data/src/schema.rs
|
||||
@@ -1,8 +1,11 @@
|
||||
+#![allow(warnings)]
|
||||
+
|
||||
table! {
|
||||
episodes (title, show_id) {
|
||||
+ rowid -> Integer,
|
||||
title -> Text,
|
||||
uri -> Nullable<Text>,
|
||||
local_uri -> Nullable<Text>,
|
||||
description -> Nullable<Text>,
|
||||
epoch -> Integer,
|
||||
length -> Nullable<Integer>,
|
||||
@@ -30,11 +33,7 @@ table! {
|
||||
uri -> Text,
|
||||
last_modified -> Nullable<Text>,
|
||||
http_etag -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
-allow_tables_to_appear_in_same_query!(
|
||||
- episodes,
|
||||
- shows,
|
||||
- source,
|
||||
-);
|
||||
+allow_tables_to_appear_in_same_query!(episodes, shows, source);
|
||||
@ -1,5 +1,7 @@
|
||||
#![allow(warnings)]
|
||||
|
||||
table! {
|
||||
episode (title, podcast_id) {
|
||||
episodes (title, show_id) {
|
||||
rowid -> Integer,
|
||||
title -> Text,
|
||||
uri -> Nullable<Text>,
|
||||
@ -10,22 +12,17 @@ table! {
|
||||
duration -> Nullable<Integer>,
|
||||
guid -> Nullable<Text>,
|
||||
played -> Nullable<Integer>,
|
||||
favorite -> Bool,
|
||||
archive -> Bool,
|
||||
podcast_id -> Integer,
|
||||
show_id -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
podcast (id) {
|
||||
shows (id) {
|
||||
id -> Integer,
|
||||
title -> Text,
|
||||
link -> Text,
|
||||
description -> Text,
|
||||
image_uri -> Nullable<Text>,
|
||||
favorite -> Bool,
|
||||
archive -> Bool,
|
||||
always_dl -> Bool,
|
||||
source_id -> Integer,
|
||||
}
|
||||
}
|
||||
@ -39,4 +36,4 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
allow_tables_to_appear_in_same_query!(episode, podcast, source,);
|
||||
allow_tables_to_appear_in_same_query!(episodes, shows, source);
|
||||
@ -1,3 +1,22 @@
|
||||
// utils.rs
|
||||
//
|
||||
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Helper utilities for accomplishing various tasks.
|
||||
|
||||
use chrono::prelude::*;
|
||||
@ -5,10 +24,10 @@ use rayon::prelude::*;
|
||||
|
||||
use url::{Position, Url};
|
||||
|
||||
use dbqueries;
|
||||
use errors::DataError;
|
||||
use models::{EpisodeCleanerQuery, Podcast, Save};
|
||||
use xdg_dirs::DL_DIR;
|
||||
use crate::dbqueries;
|
||||
use crate::errors::DataError;
|
||||
use crate::models::{EpisodeCleanerModel, Save, Show};
|
||||
use crate::xdg_dirs::DL_DIR;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@ -37,7 +56,7 @@ fn download_checker() -> Result<(), DataError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete watched `episodes` that have exceded their liftime after played.
|
||||
/// Delete watched `episodes` that have exceeded their lifetime after played.
|
||||
fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
|
||||
let mut episodes = dbqueries::get_played_cleaner_episodes()?;
|
||||
let now_utc = cleanup_date.timestamp() as i32;
|
||||
@ -49,7 +68,7 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
|
||||
let limit = ep.played().unwrap();
|
||||
if now_utc > limit {
|
||||
delete_local_content(ep)
|
||||
.map(|_| info!("Episode {:?} was deleted succesfully.", ep.local_uri()))
|
||||
.map(|_| info!("Episode {:?} was deleted successfully.", ep.local_uri()))
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri()))
|
||||
.ok();
|
||||
@ -59,7 +78,7 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
|
||||
}
|
||||
|
||||
/// Check `ep.local_uri` field and delete the file it points to.
|
||||
fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
|
||||
fn delete_local_content(ep: &mut EpisodeCleanerModel) -> Result<(), DataError> {
|
||||
if ep.local_uri().is_some() {
|
||||
let uri = ep.local_uri().unwrap().to_owned();
|
||||
if Path::new(&uri).exists() {
|
||||
@ -103,14 +122,14 @@ pub fn url_cleaner(s: &str) -> String {
|
||||
// https://rust-lang-nursery.github.io/rust-cookbook/net.html
|
||||
// #remove-fragment-identifiers-and-query-pairs-from-a-url
|
||||
match Url::parse(s) {
|
||||
Ok(parsed) => parsed[..Position::AfterPath].to_owned(),
|
||||
Ok(parsed) => parsed[..Position::AfterQuery].to_owned(),
|
||||
_ => s.trim().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the URI of a Podcast Downloads given it's title.
|
||||
/// Returns the URI of a Show Downloads given it's title.
|
||||
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
|
||||
// It might be better to make it a hash of the title or the podcast rowid
|
||||
// It might be better to make it a hash of the title or the Show rowid
|
||||
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
|
||||
|
||||
// Create the folder
|
||||
@ -123,26 +142,25 @@ pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
|
||||
/// Removes all the entries associated with the given show from the database,
|
||||
/// and deletes all of the downloaded content.
|
||||
// TODO: Write Tests
|
||||
pub fn delete_show(pd: &Podcast) -> Result<(), DataError> {
|
||||
pub fn delete_show(pd: &Show) -> Result<(), DataError> {
|
||||
dbqueries::remove_feed(pd)?;
|
||||
info!("{} was removed succesfully.", pd.title());
|
||||
info!("{} was removed successfully.", pd.title());
|
||||
|
||||
let fold = get_download_folder(pd.title())?;
|
||||
fs::remove_dir_all(&fold)?;
|
||||
info!("All the content at, {} was removed succesfully", &fold);
|
||||
info!("All the content at, {} was removed successfully", &fold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use Feed;
|
||||
use crate::Feed;
|
||||
|
||||
#[cfg(test)]
|
||||
/// Helper function that open a local file, parse the rss::Channel and gives back a Feed object.
|
||||
/// Alternative Feed constructor to be used for tests.
|
||||
pub fn get_feed(file_path: &str, id: i32) -> Feed {
|
||||
use feed::FeedBuilder;
|
||||
use crate::feed::FeedBuilder;
|
||||
use rss::Channel;
|
||||
use std::fs;
|
||||
use std::io::BufReader;
|
||||
|
||||
// open the xml file
|
||||
@ -158,61 +176,58 @@ pub fn get_feed(file_path: &str, id: i32) -> Feed {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
extern crate tempdir;
|
||||
|
||||
use self::tempdir::TempDir;
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use failure::Error;
|
||||
use tempdir::TempDir;
|
||||
|
||||
use database::truncate_db;
|
||||
use models::NewEpisodeBuilder;
|
||||
use crate::database::truncate_db;
|
||||
use crate::models::NewEpisodeBuilder;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
fn helper_db() -> TempDir {
|
||||
fn helper_db() -> Result<TempDir, Error> {
|
||||
// Clean the db
|
||||
truncate_db().unwrap();
|
||||
truncate_db()?;
|
||||
// Setup tmp file stuff
|
||||
let tmp_dir = TempDir::new("hammond_test").unwrap();
|
||||
let tmp_dir = TempDir::new("podcasts_test")?;
|
||||
let valid_path = tmp_dir.path().join("virtual_dl.mp3");
|
||||
let bad_path = tmp_dir.path().join("invalid_thing.mp3");
|
||||
let mut tmp_file = File::create(&valid_path).unwrap();
|
||||
writeln!(tmp_file, "Foooo").unwrap();
|
||||
let mut tmp_file = File::create(&valid_path)?;
|
||||
writeln!(tmp_file, "Foooo")?;
|
||||
|
||||
// Setup episodes
|
||||
let n1 = NewEpisodeBuilder::default()
|
||||
.title("foo_bar".to_string())
|
||||
.podcast_id(0)
|
||||
.show_id(0)
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_episode()
|
||||
.unwrap();
|
||||
.to_episode()?;
|
||||
|
||||
let n2 = NewEpisodeBuilder::default()
|
||||
.title("bar_baz".to_string())
|
||||
.podcast_id(1)
|
||||
.show_id(1)
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_episode()
|
||||
.unwrap();
|
||||
.to_episode()?;
|
||||
|
||||
let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.podcast_id()).unwrap();
|
||||
let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.podcast_id()).unwrap();
|
||||
let mut ep1 = dbqueries::get_episode_cleaner_from_pk(n1.title(), n1.show_id())?;
|
||||
let mut ep2 = dbqueries::get_episode_cleaner_from_pk(n2.title(), n2.show_id())?;
|
||||
ep1.set_local_uri(Some(valid_path.to_str().unwrap()));
|
||||
ep2.set_local_uri(Some(bad_path.to_str().unwrap()));
|
||||
|
||||
ep1.save().unwrap();
|
||||
ep2.save().unwrap();
|
||||
ep1.save()?;
|
||||
ep2.save()?;
|
||||
|
||||
tmp_dir
|
||||
Ok(tmp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_checker() {
|
||||
let tmp_dir = helper_db();
|
||||
download_checker().unwrap();
|
||||
let episodes = dbqueries::get_downloaded_episodes().unwrap();
|
||||
fn test_download_checker() -> Result<(), Error> {
|
||||
let tmp_dir = helper_db()?;
|
||||
download_checker()?;
|
||||
let episodes = dbqueries::get_downloaded_episodes()?;
|
||||
let valid_path = tmp_dir.path().join("virtual_dl.mp3");
|
||||
|
||||
assert_eq!(episodes.len(), 1);
|
||||
@ -221,69 +236,75 @@ mod tests {
|
||||
episodes.first().unwrap().local_uri()
|
||||
);
|
||||
|
||||
let _tmp_dir = helper_db();
|
||||
download_checker().unwrap();
|
||||
let episode = dbqueries::get_episode_from_pk("bar_baz", 1).unwrap();
|
||||
let _tmp_dir = helper_db()?;
|
||||
download_checker()?;
|
||||
let episode = dbqueries::get_episode_cleaner_from_pk("bar_baz", 1)?;
|
||||
assert!(episode.local_uri().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_cleaner() {
|
||||
let _tmp_dir = helper_db();
|
||||
let mut episode: EpisodeCleanerQuery =
|
||||
dbqueries::get_episode_from_pk("foo_bar", 0).unwrap().into();
|
||||
fn test_download_cleaner() -> Result<(), Error> {
|
||||
let _tmp_dir = helper_db()?;
|
||||
let mut episode: EpisodeCleanerModel =
|
||||
dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?.into();
|
||||
|
||||
let valid_path = episode.local_uri().unwrap().to_owned();
|
||||
delete_local_content(&mut episode).unwrap();
|
||||
delete_local_content(&mut episode)?;
|
||||
assert_eq!(Path::new(&valid_path).exists(), false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_played_cleaner_expired() {
|
||||
let _tmp_dir = helper_db();
|
||||
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
||||
fn test_played_cleaner_expired() -> Result<(), Error> {
|
||||
let _tmp_dir = helper_db()?;
|
||||
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?;
|
||||
let cleanup_date = Utc::now() - Duration::seconds(1000);
|
||||
let epoch = cleanup_date.timestamp() as i32 - 1;
|
||||
episode.set_played(Some(epoch));
|
||||
episode.save().unwrap();
|
||||
episode.save()?;
|
||||
let valid_path = episode.local_uri().unwrap().to_owned();
|
||||
|
||||
// This should delete the file
|
||||
played_cleaner(cleanup_date).unwrap();
|
||||
played_cleaner(cleanup_date)?;
|
||||
assert_eq!(Path::new(&valid_path).exists(), false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_played_cleaner_none() {
|
||||
let _tmp_dir = helper_db();
|
||||
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
||||
fn test_played_cleaner_none() -> Result<(), Error> {
|
||||
let _tmp_dir = helper_db()?;
|
||||
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?;
|
||||
let cleanup_date = Utc::now() - Duration::seconds(1000);
|
||||
let epoch = cleanup_date.timestamp() as i32 + 1;
|
||||
episode.set_played(Some(epoch));
|
||||
episode.save().unwrap();
|
||||
episode.save()?;
|
||||
let valid_path = episode.local_uri().unwrap().to_owned();
|
||||
|
||||
// This should not delete the file
|
||||
played_cleaner(cleanup_date).unwrap();
|
||||
played_cleaner(cleanup_date)?;
|
||||
assert_eq!(Path::new(&valid_path).exists(), true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_cleaner() {
|
||||
let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3";
|
||||
let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184";
|
||||
fn test_url_cleaner() -> Result<(), Error> {
|
||||
let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184";
|
||||
let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184#foobar";
|
||||
|
||||
assert_eq!(url_cleaner(bad_url), good_url);
|
||||
assert_eq!(url_cleaner(good_url), good_url);
|
||||
assert_eq!(url_cleaner(&format!(" {}\t\n", bad_url)), good_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
// This test needs access to local system so we ignore it by default.
|
||||
#[ignore]
|
||||
fn test_get_dl_folder() {
|
||||
fn test_get_dl_folder() -> Result<(), Error> {
|
||||
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
|
||||
assert_eq!(get_download_folder("foo").unwrap(), foo_);
|
||||
assert_eq!(get_download_folder("foo")?, foo_);
|
||||
let _ = fs::remove_dir_all(foo_);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
14
podcasts-data/tests/export_test.opml
Normal file
14
podcasts-data/tests/export_test.opml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>GNOME Podcasts Subscriptions</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline text="David Harvey's Anti-Capitalist Chronicles" title="David Harvey's Anti-Capitalist Chronicles" type="rss" xmlUrl="https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss" htmlUrl="https://www.democracyatwork.info/acc" />
|
||||
<outline text="Greater Than Code" title="Greater Than Code" type="rss" xmlUrl="https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast" htmlUrl="https://www.greaterthancode.com/" />
|
||||
<outline text="Intercepted with Jeremy Scahill" title="Intercepted with Jeremy Scahill" type="rss" xmlUrl="https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.com/InterceptedWithJeremyScahill" htmlUrl="https://theintercept.com/podcasts" />
|
||||
<outline text="LINUX Unplugged Podcast" title="LINUX Unplugged Podcast" type="rss" xmlUrl="https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged" htmlUrl="http://www.jupiterbroadcasting.com/" />
|
||||
<outline text="Steal the Stars" title="Steal the Stars" type="rss" xmlUrl="https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars" htmlUrl="http://tor-labs.com/" />
|
||||
<outline text="The Tip Off" title="The Tip Off" type="rss" xmlUrl="https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff" htmlUrl="http://www.acast.com/thetipoff" />
|
||||
</body>
|
||||
</opml>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user