Compare commits
366
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd52c5afdf | ||
|
|
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 | ||
|
|
c4e6fcc451 | ||
|
|
22ae5242b2 | ||
|
|
4d77281249 | ||
|
|
b77a373efa | ||
|
|
463c3908d4 | ||
|
|
79239a2e01 | ||
|
|
41073615e9 | ||
|
|
bd12b09cbc | ||
|
|
7d598bb1d0 | ||
|
|
ccd3e3ab2c | ||
|
|
118dac5a1a | ||
|
|
7035fe05c4 | ||
|
|
af7331c6c6 | ||
|
|
4d2b64e79d | ||
|
|
d47bbd6131 | ||
|
|
54fafa07a2 | ||
|
|
97eef9149c | ||
|
|
b95e70a8c4 | ||
|
|
a16d7de1ac | ||
|
|
6f590a64af | ||
|
|
00e747eb5f | ||
|
|
2d8164cf0a | ||
|
|
be1a8df3ef | ||
|
|
e8ca2faaa7 | ||
|
|
83f9284b05 | ||
|
|
6e5c70ab71 | ||
|
|
ab4f958b5f | ||
|
|
2711ebca68 | ||
|
|
041684b13a | ||
|
|
777a2102f8 | ||
|
|
65949c5af5 | ||
|
|
e40f880b9e | ||
|
|
f9b34bbd50 | ||
|
|
f06dbd0562 | ||
|
|
4a6f5c6268 | ||
|
|
b5dbfb1a86 | ||
|
|
d86a17f76e | ||
|
|
37c7f20256 | ||
|
|
f324407c9c | ||
|
|
c96b39d597 | ||
|
|
d4d89a56e9 | ||
|
|
f19ad133c6 | ||
|
|
00d9019f29 | ||
|
|
8951a6e237 | ||
|
|
b0ac037964 | ||
|
|
2c8ff2d2f2 | ||
|
|
03bd951848 | ||
|
|
63e2ea987e | ||
|
|
115df7f884 | ||
|
|
ed94d34589 | ||
|
|
dc5ff9d809 | ||
|
|
72a6832571 | ||
|
|
b0c692ab7d | ||
|
|
06e8bc14f4 | ||
|
|
3d160fc35c | ||
|
|
734f85a517 | ||
|
|
a56a80db88 | ||
|
|
c4ed90dd5a | ||
|
|
f173b326a4 | ||
|
|
5e302290de | ||
|
|
75af3c7f2b | ||
|
|
d7aec6fdfb | ||
|
|
9a3586fd1c | ||
|
|
063bcbd0e5 | ||
|
|
a76e69e05d | ||
|
|
e560cce879 | ||
|
|
6406c3af13 | ||
|
|
173d2d3a3a | ||
|
|
9a5cc1595d | ||
|
|
d1962ab745 | ||
|
|
af5b27d0fc | ||
|
|
a9196d27d6 | ||
|
|
2f1ea12cfa | ||
|
|
37a408e58a | ||
|
|
736a993284 | ||
|
|
09359a8df3 | ||
|
|
a68987f257 | ||
|
|
3b5831f317 | ||
|
|
5336981154 | ||
|
|
df417fa619 | ||
|
|
509bbe25d2 | ||
|
|
f49012ab51 | ||
|
|
e4fc7c336e | ||
|
|
0e4430bae4 | ||
|
|
f811a9c8f4 | ||
|
|
771999c603 | ||
|
|
18820202d7 | ||
|
|
abc8fb988f | ||
|
|
885c525d7b | ||
|
|
418a2f02b2 | ||
|
|
031078284c | ||
|
|
3c7ba8c9d9 | ||
|
|
835078a84c | ||
|
|
049418c2f5 | ||
|
|
7c03266d16 | ||
|
|
627f06ea9f | ||
|
|
9f84178182 | ||
|
|
54641fc3c5 | ||
|
|
b8995d838a | ||
|
|
a0154c5919 | ||
|
|
08365c412a | ||
|
|
2d291a08fc | ||
|
|
7a17b3df4b | ||
|
|
0589f2fe2a | ||
|
|
a9abd75b51 | ||
|
|
bc6eeec663 | ||
|
|
3132856efe | ||
|
|
4db7628eed | ||
|
|
7b71f59d3e | ||
|
|
76c177bc0f | ||
|
|
50a508b596 | ||
|
|
59c634f626 | ||
|
|
08ebb9e7d6 | ||
|
|
f5f0a5b873 | ||
|
|
1036176e51 | ||
|
|
c6a24e839a | ||
|
|
87421ce74d | ||
|
|
f94ccb9947 | ||
|
|
1f1d4af8ba | ||
|
|
633803e0ad | ||
|
|
74fb8dc75c | ||
|
|
2523a0cf90 | ||
|
|
47a58a9e65 | ||
|
|
27c4fd9b30 | ||
|
|
f3904c599b | ||
|
|
b86f288e86 | ||
|
|
67bdd3664a | ||
|
|
8d4fdb8ece | ||
|
|
0720222423 | ||
|
|
7bca841a1a | ||
|
|
572ab86bc4 | ||
|
|
29cf5940f5 | ||
|
|
4b4f5c39d4 | ||
|
|
5069430a3a | ||
|
|
28d7373779 | ||
|
|
9d5fa04d49 | ||
|
|
43bf8b3f15 | ||
|
|
ed80605755 | ||
|
|
cc84a4637d | ||
|
|
701b759ba2 | ||
|
|
9cb2782ef9 | ||
|
|
5cd0a3c451 | ||
|
|
b74dbb74bb | ||
|
|
d332636dd4 | ||
|
|
32654f6cb2 | ||
|
|
14a90e7138 | ||
|
|
dd2366a15e | ||
|
|
370ba2d461 | ||
|
|
ef655ef5e0 | ||
|
|
5cc550c830 | ||
|
|
52cbe67756 | ||
|
|
c910e0af40 | ||
|
|
7a74534285 | ||
|
|
916775462d | ||
|
|
3d98600126 | ||
|
|
7ba834ee8d | ||
|
|
1c527cba03 | ||
|
|
2d7ba7b246 | ||
|
|
4ed70a8011 | ||
|
|
7a3a148359 | ||
|
|
e07e35110d | ||
|
|
a463753c84 | ||
|
|
a946ddfab1 | ||
|
|
af1cb43bd6 | ||
|
|
935d61324f | ||
|
|
10e016f2ea | ||
|
|
154655d571 | ||
|
|
491cd8f01c | ||
|
|
2b711ff04c | ||
|
|
07eadd2364 | ||
|
|
7086afe73d | ||
|
|
f21398357b | ||
|
|
c338802329 | ||
|
|
c3076e748e | ||
|
|
1595256c86 | ||
|
|
0623592f75 | ||
|
|
f2444f151c | ||
|
|
710a3f2552 | ||
|
|
6071c664e7 | ||
|
|
e203815f4f | ||
|
|
c3658080d3 | ||
|
|
8703470010 | ||
|
|
88cc7e6fec | ||
|
|
badcbc32c6 | ||
|
|
daa8f15ce9 | ||
|
|
89ee174ded | ||
|
|
cc03c2407b | ||
|
|
89254025f3 | ||
|
|
f693c986ec | ||
|
|
3c7f3ecb56 | ||
|
|
ef3809ed25 | ||
|
|
f5ddb7107e | ||
|
|
1d32018c02 | ||
|
|
c458b27573 | ||
|
|
524e0bb0a8 | ||
|
|
d525d1fe59 | ||
|
|
bdc6264701 | ||
|
|
7e2640e2d0 | ||
|
|
822a72efde | ||
|
|
192b13e393 | ||
|
|
2497cb31d0 | ||
|
|
446a0ede54 | ||
|
|
3d39638c99 | ||
|
|
69e87d129a | ||
|
|
f7a7510322 | ||
|
|
e9f2ba47f2 | ||
|
|
b2c95e5a73 | ||
|
|
482ed7c3c6 | ||
|
|
e497f73316 | ||
|
|
28965dc2b1 | ||
|
|
5425ca35b3 | ||
|
|
9f191d0ab8 | ||
|
|
c2a3ce5096 | ||
|
|
1af06f2e0d | ||
|
|
2f2f11b7bc | ||
|
|
260e6015a1 | ||
|
|
7696014545 | ||
|
|
2457e95f0e | ||
|
|
327c907463 | ||
|
|
1b558d3b30 | ||
|
|
a0d55417cd | ||
|
|
f182d441d1 | ||
|
|
e14f215793 | ||
|
|
34536f4e21 | ||
|
|
bc2da6e59e | ||
|
|
afdedc7575 | ||
|
|
69a7a9b180 | ||
|
|
a7540583d6 | ||
|
|
a253d7ebf5 | ||
|
|
3886402f8e | ||
|
|
97e402b980 | ||
|
|
93e15af209 | ||
|
|
9f3a5a13b3 | ||
|
|
3a0fb4bdec | ||
|
|
8a460930c6 | ||
|
|
a11c4c9bd2 | ||
|
|
d4b98b5cb2 | ||
|
|
fdf3908494 | ||
|
|
cc052eb450 | ||
|
|
4535c3005d | ||
|
|
030fed6d12 | ||
|
|
fc9579cd51 | ||
|
|
74712b5410 | ||
|
|
05628a2529 | ||
|
|
1bdd2f2f5b | ||
|
|
3af6e103aa | ||
|
|
3dcc20ae86 | ||
|
|
8a18630ae5 | ||
|
|
b87c331b12 | ||
|
|
9da2414e8b | ||
|
|
285291b2ed | ||
|
|
dbbb4e589e | ||
|
|
064879c4ce | ||
|
|
8614922213 | ||
|
|
745ea0ca10 | ||
|
|
99bc80c15b | ||
|
|
3423d854e1 | ||
|
|
483fd090f1 | ||
|
|
82988b6011 | ||
|
|
d1821163c2 | ||
|
|
7de118adeb | ||
|
|
abe7215bc0 | ||
|
|
7b064e63b9 | ||
|
|
f6890c709f | ||
|
|
e4814dbfbc | ||
|
|
8261b32c99 | ||
|
|
9a73520b25 | ||
|
|
10db4f7210 | ||
|
|
94f6fdcbe7 | ||
|
|
bb467b7aba | ||
|
|
3da503cdea | ||
|
|
b062f0a19f | ||
|
|
2a6e0b0e07 | ||
|
|
1558ee2177 | ||
|
|
3d542e5554 | ||
|
|
c61938ba62 | ||
|
|
c856b88008 | ||
|
|
fce3684113 | ||
|
|
ae25dd65bf | ||
|
|
a88a1c5f1f | ||
|
|
0cd678cc1d | ||
|
|
c9bf58af66 | ||
|
|
ed87a00225 | ||
|
|
bcc3608c04 | ||
|
|
038d28779c | ||
|
|
973d47ee05 | ||
|
|
e803e11c81 | ||
|
|
f50c990d93 | ||
|
|
72eef6f104 | ||
|
|
5ccdb5d100 | ||
|
|
4b8fceaa7d | ||
|
|
a24c9b1350 | ||
|
|
8913b7aedb | ||
|
|
978edfc11f | ||
|
|
f4b41d0fd3 | ||
|
|
20162a16a8 | ||
|
|
73f7bfa64b | ||
|
|
159b0d92dd | ||
|
|
02de2059db | ||
|
|
bdf8901dd8 | ||
|
|
2fbc833ebe | ||
|
|
f7b5b35374 | ||
|
|
46bd23cf66 | ||
|
|
6d9dfe6fe1 | ||
|
|
fc48ce9c47 | ||
|
|
3a9a2f4033 | ||
|
|
f0ce0eb653 | ||
|
|
23979b8f22 | ||
|
|
e22a78fac6 | ||
|
|
7690cb1356 | ||
|
|
a96f4c57c9 |
+1
-5
@@ -3,11 +3,7 @@ target/
|
|||||||
Cargo.lock
|
Cargo.lock
|
||||||
.vscode
|
.vscode
|
||||||
*.ui~
|
*.ui~
|
||||||
*.gresource
|
resources.gresource
|
||||||
_build
|
_build
|
||||||
vendor/
|
vendor/
|
||||||
.flatpak-builder/
|
|
||||||
flatpak-build/
|
|
||||||
repo/
|
|
||||||
Makefile
|
|
||||||
.criterion
|
.criterion
|
||||||
|
|||||||
+130
-47
@@ -1,61 +1,144 @@
|
|||||||
stages:
|
stages:
|
||||||
# meson uses cargo to do the build
|
|
||||||
# so it's ok to have the tests first.
|
|
||||||
- test
|
- test
|
||||||
# - build
|
|
||||||
- lint
|
- lint
|
||||||
|
- review
|
||||||
before_script:
|
|
||||||
- apt-get update -yqq
|
|
||||||
- apt-get install -yqq --no-install-recommends build-essential
|
|
||||||
- apt-get install -yqq --no-install-recommends libgtk-3-dev
|
|
||||||
# - apt-get install -yqq --no-install-recommends meson
|
|
||||||
|
|
||||||
.cargo_test_template: &cargo_test
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- rustc --version && cargo --version
|
|
||||||
# Force regeneration of gresources regardless of artifacts chage
|
|
||||||
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
|
|
||||||
- cargo build
|
|
||||||
- cargo test --verbose -- --test-threads=1
|
|
||||||
- cargo test --verbose -- --test-threads=1 --ignored
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# RUSTFLAGS: "-C link-dead-code"
|
BUNDLE: "hammond-dev.flatpak"
|
||||||
RUST_BACKTRACE: "FULL"
|
|
||||||
CARGO_HOME: $CI_PROJECT_DIR/cargo
|
|
||||||
|
|
||||||
stable:test:
|
.cargo_cache_template: &cargo_cache
|
||||||
# https://hub.docker.com/_/rust/
|
cache:
|
||||||
image: "rust"
|
# JOB_NAME - Each job will have it's own cache
|
||||||
<<: *cargo_test
|
# COMMIT_REF_SLUG = Lowercase name of the branch
|
||||||
|
# ^ Keep diffrerent caches for each branch
|
||||||
|
key: "$CI_JOB_NAME"
|
||||||
|
paths:
|
||||||
|
- target/
|
||||||
|
- .cargo_cache/
|
||||||
|
|
||||||
# nightly:test:
|
.cargo_test_template: &cargo_test
|
||||||
# # https://hub.docker.com/r/rustlang/rust/
|
stage: test
|
||||||
# image: "rustlang/rust:nightly"
|
|
||||||
# <<: *cargo_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
|
||||||
|
|
||||||
|
flatpak:
|
||||||
|
image: registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.28
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 on nightly
|
||||||
# Exits and builds fails if on bad format
|
# Exits and builds fails if on bad format
|
||||||
rustfmt:
|
rustfmt:
|
||||||
image: "rustlang/rust:nightly"
|
image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:nightly"
|
||||||
stage: lint
|
stage: lint
|
||||||
variables:
|
script:
|
||||||
CFG_RELEASE_CHANNEL: "nightly"
|
- rustc -Vv && cargo -Vv
|
||||||
script:
|
- cargo fmt --version
|
||||||
- rustc --version && cargo --version
|
- cargo fmt --all -- --check
|
||||||
- cargo install rustfmt-nightly --force
|
|
||||||
- cargo fmt --all -- --write-mode=diff
|
|
||||||
|
|
||||||
# Configure and run clippy on nightly
|
# Configure and run clippy on nightly
|
||||||
# Only fails on errors atm.
|
# Only fails on errors atm.
|
||||||
# clippy:
|
clippy:
|
||||||
# image: "rustlang/rust:nightly"
|
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly"
|
||||||
# stage: lint
|
stage: lint
|
||||||
# script:
|
variables:
|
||||||
# - rustc --version && cargo --version
|
RUSTFLAGS: "--cfg rayon_unstable"
|
||||||
# - cargo install clippy --force
|
CARGO_HOME: ".cargo_cache"
|
||||||
# # Force regeneration of gresources regardless of artifacts chage
|
script:
|
||||||
# - cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
|
- rustc --version && cargo --version
|
||||||
# - cargo clippy --all
|
- 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
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
|
-->
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# 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
|
||||||
|
-->
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
+89
-16
@@ -5,27 +5,100 @@ 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).
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added:
|
||||||
|
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown) [!33](https://gitlab.gnome.org/World/hammond/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/hammond/merge_requests/33)
|
||||||
|
- The `EpisdeWidget` was refactored. [!38](https://gitlab.gnome.org/World/hammond/merge_requests/38)
|
||||||
|
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/hammond/merge_requests/37)
|
||||||
|
|
||||||
|
### Removed:
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
- ShowsView now rembmers the vertical alignment of the scrollbar between refreshes. 4d2b64e79d8518454b3677612664cd32044cf837
|
||||||
|
|
||||||
|
### Changed:
|
||||||
|
- Minimum `rustc` version requirment was bumped to `1.26`
|
||||||
|
- Some animations should be smoother now. 7d598bb1d08b05fd5ab532657acdad967c0afbc3
|
||||||
|
- InAppNotification now can be used to propagate some erros to the user. 7035fe05c4741b3e7ccce6827f72766226d5fc0a and 118dac5a1ab79c0b4ebe78e88256a4a38b138c04
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- Fixed a of by one bug in the `ShowsView` where the last show was never shown. bd12b09cbc8132fd39a266fd091e24bc6c3c040f
|
||||||
|
|
||||||
|
## [0.3.2] - 2018-05-07
|
||||||
|
### Added:
|
||||||
|
- Vies now have a new fancy scrolling animation when they are refereshed.
|
||||||
|
|
||||||
|
### Changed:
|
||||||
|
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
|
||||||
|
[#7](https://gitlab.gnome.org/World/hammond/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)
|
||||||
|
- `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
|
||||||
|
- `ShowWidget` layout was changed 9a5cc1595d982f3232ee7595b83b6512ac8f6c88
|
||||||
|
- `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)
|
||||||
|
- Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
|
||||||
|
|
||||||
|
### Removed:
|
||||||
|
- Removed the custom configuration file and replaced instructions to just use meson. 1f1d4af8ba7db8f56435d13a1c191ecff3d4a85b
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
- 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)
|
||||||
|
- Hammond now remembers the window size and position. (Rowan Lewis)
|
||||||
|
[#50](https://gitlab.gnome.org/World/hammond/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)
|
||||||
|
- Shows without episodes now display an empty message similar to EmptyView.
|
||||||
|
[#44](https://gitlab.gnome.org/World/hammond/issues/44)
|
||||||
|
|
||||||
|
### Changed:
|
||||||
|
- EpisdeWidget has been reimplemented as a compile time state machine.
|
||||||
|
[!18](https://gitlab.gnome.org/World/hammond/merge_requests/18)
|
||||||
|
- Content Views no longer scroll horizontally when shrunk bellow their minimum size.
|
||||||
|
[#35](https://gitlab.gnome.org/World/hammond/issues/35)
|
||||||
|
- Some requests now use the Tor Browser's user agent. (Rowan Lewis)
|
||||||
|
[#53](https://gitlab.gnome.org/World/hammond/issues/53)
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- Double border aroun the main window was fixed. (Rowan Lewis)
|
||||||
|
[#52](https://gitlab.gnome.org/World/hammond/issues/52)
|
||||||
|
|
||||||
## [0.3.0] - 2018-02-11
|
## [0.3.0] - 2018-02-11
|
||||||
|
- Tobias Bernard Redesigned the whole Gtk+ client.
|
||||||
* Tobias Bernard Redesigned the whole Gtk+ client.
|
- Complete re-write of hammond-data and hammond-gtk modules.
|
||||||
* Complete re-write of hammond-data and hammond-gtk modules.
|
- Error handling for all crates was migrated from error-chain to Failure.
|
||||||
* Error handling for all crates was migrated from error-chain to Failure.
|
- Hammond-data now uses futures to parse feeds.
|
||||||
* Hammond-data now uses futures to parse feeds.
|
- Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
|
||||||
* Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
|
|
||||||
|
|
||||||
## [0.2.0] - 2017-11-28
|
## [0.2.0] - 2017-11-28
|
||||||
|
- Database Schema Breaking Changes.
|
||||||
* Database Schema Breaking Changes.
|
- Added url sanitization. #4.
|
||||||
* Added url sanitization. #4.
|
- Reworked and refactored of the hammond-data API.
|
||||||
* Reworked and refactored of the hammond-data API.
|
- Added some more unit tests
|
||||||
* Added some more unit tests
|
- Documented hammond-data public API.
|
||||||
* Documented hammond-data public API.
|
|
||||||
|
|
||||||
## [0.1.1] - 2017-11-13
|
## [0.1.1] - 2017-11-13
|
||||||
|
- Added appdata.xml file
|
||||||
* Added appdata.xml file
|
|
||||||
|
|
||||||
## [0.1.0] - 2017-11-13
|
## [0.1.0] - 2017-11-13
|
||||||
|
- Initial Release
|
||||||
Initial Release
|
|
||||||
+2
-2
@@ -12,7 +12,7 @@ Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOf
|
|||||||
## Source repository
|
## Source repository
|
||||||
|
|
||||||
Hammond's main source repository is at gitlab.gnome.org. You can view
|
Hammond's main source repository is at gitlab.gnome.org. You can view
|
||||||
the web interface [here](https://gitlab.gnome.org/alatiera/hammond)
|
the web interface [here](https://gitlab.gnome.org/World/hammond)
|
||||||
|
|
||||||
Development happens in the master branch.
|
Development happens in the master branch.
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ In order to run the test suite use the following: `cargo test -- --test-threads=
|
|||||||
# Issues, issues and more issues!
|
# Issues, issues and more issues!
|
||||||
|
|
||||||
There are many ways you can contribute to Hammond, and all of them involve creating 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/alatiera/Hammond/issues). This is the entry point for your contribution.
|
in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/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
|
To create an effective and high quality ticket, try to put the following information on your
|
||||||
ticket:
|
ticket:
|
||||||
|
|||||||
Generated
+1016
-665
File diff suppressed because it is too large
Load Diff
@@ -7,4 +7,3 @@ members = [
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = false
|
debug = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
# Hammond
|
# Hammond
|
||||||
|
|
||||||
## A Podcast Client for the GNOME Desktop written in Rust.
|
### A Podcast Client for GNOME written in Rust.
|
||||||
|
|
||||||
[](https://gitlab.gnome.org/alatiera/Hammond/commits/master)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* TBA
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -14,36 +8,27 @@
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
As of January 19 2018, Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) Nightly
|
Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) >= 3.28.
|
||||||
and any Stable release of Gnome Builder >= 3.28.0.
|
|
||||||
|
|
||||||
Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
|
Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
|
||||||
|
|
||||||
Manually:
|
|
||||||
|
|
||||||
The following steps assume you have a working installation of rustc and cargo.
|
|
||||||
If you dont take a look at [rustup.rs](https://rustup.rs/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://gitlab.gnome.org/alatiera/hammond.git
|
|
||||||
cd Hammond/
|
|
||||||
cargo run -p hammond-gtk --release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Broken Feeds
|
## Broken Feeds
|
||||||
|
|
||||||
Found a feed that does not work in Hammond?
|
Found a feed that does not work in Hammond?
|
||||||
Please [open an issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
|
Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
|
||||||
|
|
||||||
## Getting in Touch
|
## Getting in Touch
|
||||||
|
|
||||||
If you have any questions regarding the use or development of Hammond,
|
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)
|
want to discuss design or simply hang out, please join us in `#hammond` on
|
||||||
|
[irc.gnome.org.][irc] or [matrix][matrix].
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
There isn't much documentation yet, so you will probably have question about parts of the Code.
|
There isn't much documentation yet, so you will probably have question about parts of the Code.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
### Flatpak
|
### Flatpak
|
||||||
|
|
||||||
Flatpak is the reccomended way of building and installing Hammond.
|
Flatpak is the reccomended way of building and installing Hammond.
|
||||||
@@ -54,55 +39,58 @@ Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add flathub repo
|
# Add flathub repo
|
||||||
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
flatpak --user remote-add flathub --if-not-exists https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
# Add the gnome-nightly repo
|
# Add the gnome-nightly repo
|
||||||
flatpak --user remote-add gnome-nightly https://sdk.gnome.org/gnome-nightly.flatpakrepo
|
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
|
# Install the required rust-stable extension from flathub
|
||||||
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
|
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
|
||||||
flatpak-builder --repo=repo hammond org.gnome.Hammond.json --force-clean
|
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
|
||||||
flatpak build-bundle repo hammond org.gnome.Hammond
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building from soure
|
To install the resulting flatpak you can do:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
|
||||||
|
flatpak install --user --bundle hammond.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building from soure
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://gitlab.gnome.org/alatiera/hammond.git
|
git clone https://gitlab.gnome.org/World/hammond.git
|
||||||
cd Hammond/
|
cd hammond/
|
||||||
./configure --prefix=/usr/local
|
meson --prefix=/usr build
|
||||||
make && sudo make install
|
ninja -C build
|
||||||
|
sudo ninja -C build install
|
||||||
```
|
```
|
||||||
|
|
||||||
**Additional:**
|
#### Dependencies
|
||||||
|
|
||||||
You can run `sudo make uninstall` for removal
|
* Rust stable 1.26 or later along with cargo.
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
* Rust stable 1.22 or later.
|
|
||||||
* Gtk+ 3.22 or later
|
* Gtk+ 3.22 or later
|
||||||
* Meson
|
* Meson
|
||||||
|
* A network connection
|
||||||
|
|
||||||
|
Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first
|
||||||
|
|
||||||
**Debian/Ubuntu**
|
**Debian/Ubuntu**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
apt-get update -yqq
|
apt-get update -yqq
|
||||||
apt-get install -yqq --no-install-recommends build-essential
|
apt-get install -yqq --no-install-recommends build-essential
|
||||||
apt-get install -yqq --no-install-recommends libgtk-3-dev meson
|
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fedora**
|
**Fedora**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dnf install -y gtk3-devel glib2-devel openssl-devel sqlite-devel meson
|
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 PR or an Issue to note it.
|
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.
|
||||||
```sh
|
|
||||||
git clone https://gitlab.gnome.org/alatiera/Hammond.git
|
|
||||||
cd Hammond/
|
|
||||||
cargo build --all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -110,11 +98,11 @@ There alot of thins yet to be done.
|
|||||||
|
|
||||||
If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines].
|
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/alatiera/Hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
|
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=).
|
||||||
|
|
||||||
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
|
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
|
||||||
|
|
||||||
[contribution-guidelines]: https://gitlab.gnome.org/alatiera/Hammond/blob/master/CONTRIBUTING.md
|
[contribution-guidelines]: https://gitlab.gnome.org/World/hammond/blob/master/CONTRIBUTING.md
|
||||||
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -134,7 +122,7 @@ $ tree -d
|
|||||||
│ ├── resources # GResources folder
|
│ ├── resources # GResources folder
|
||||||
│ │ └── gtk # Contains the glade.ui files.
|
│ │ └── gtk # Contains the glade.ui files.
|
||||||
│ └── src
|
│ └── src
|
||||||
│ ├── views # Contains the Empty, Episodes and Shows view.
|
│ ├── stacks # Contains the gtk Stacks that hold all the different views.
|
||||||
│ └── widgets # Contains custom widgets such as Show and Episode.
|
│ └── widgets # Contains custom widgets such as Show and Episode.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -151,3 +139,7 @@ Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Desi
|
|||||||
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
|
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/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](https://gitlab.gnome.org/danigm/fractal) project.
|
||||||
|
|
||||||
|
[vendor]: https://github.com/alexcrichton/cargo-vendor
|
||||||
|
[irc]: irc://irc.gnome.org/#hammond
|
||||||
|
[matrix]: https://matrix.to/#/#hammond:matrix.org
|
||||||
@@ -4,12 +4,11 @@
|
|||||||
|
|
||||||
## Priorities
|
## Priorities
|
||||||
|
|
||||||
- [ ] Unplayed Only and Downloaded only view.
|
|
||||||
- [ ] OPML import/export // Probably need to create a crate.
|
- [ ] OPML import/export // Probably need to create a crate.
|
||||||
|
|
||||||
## Second
|
## 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
|
- [ ] Episode queue
|
||||||
- [ ] Embedded player
|
- [ ] Embedded player
|
||||||
- [ ] MPRIS integration
|
- [ ] MPRIS integration
|
||||||
@@ -19,14 +18,8 @@
|
|||||||
|
|
||||||
- [ ] Download Queue
|
- [ ] Download Queue
|
||||||
- [ ] Ability to Stream content on demand
|
- [ ] 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?
|
- [ ] Integrate with Itunes API for various crap?
|
||||||
- [ ] YoutubeFeeds?
|
- [ ] YoutubeFeeds?
|
||||||
|
|
||||||
## Rest Tasks
|
## 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.
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Adapted from:
|
|
||||||
# https://gitlab.gnome.org/danigm/libgepub/blob/27f0d374e0c8f6fa972dbd111d4ce0c0f3096914/configure_meson
|
|
||||||
|
|
||||||
# configure script adapter for Meson
|
|
||||||
# Based on build-api: https://github.com/cgwalters/build-api
|
|
||||||
# Copyright 2010, 2011, 2013 Colin Walters <walters@verbum.org>
|
|
||||||
# Copyright 2016, 2017 Emmanuele Bassi
|
|
||||||
# Copyright 2017 Iñigo Martínez <inigomartinez@gmail.com>
|
|
||||||
# Licensed under the new-BSD license (http://www.opensource.org/licenses/bsd-license.php)
|
|
||||||
|
|
||||||
# Build API variables:
|
|
||||||
|
|
||||||
# Little helper function for reading args from the commandline.
|
|
||||||
# it automatically handles -a b and -a=b variants, and returns 1 if
|
|
||||||
# we need to shift $3.
|
|
||||||
read_arg() {
|
|
||||||
# $1 = arg name
|
|
||||||
# $2 = arg value
|
|
||||||
# $3 = arg parameter
|
|
||||||
local rematch='^[^=]*=(.*)$'
|
|
||||||
if [[ $2 =~ $rematch ]]; then
|
|
||||||
read "$1" <<< "${BASH_REMATCH[1]}"
|
|
||||||
else
|
|
||||||
read "$1" <<< "$3"
|
|
||||||
# There is no way to shift our callers args, so
|
|
||||||
# return 1 to indicate they should do it instead.
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitycheck() {
|
|
||||||
# $1 = arg name
|
|
||||||
# $1 = arg command
|
|
||||||
# $2 = arg alternates
|
|
||||||
local cmd=$( which $2 2>/dev/null )
|
|
||||||
|
|
||||||
if [ -x "$cmd" ]; then
|
|
||||||
read "$1" <<< "$cmd"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
test -z $3 || {
|
|
||||||
for alt in $3; do
|
|
||||||
cmd=$( which $alt 2>/dev/null )
|
|
||||||
|
|
||||||
if [ -x "$cmd" ]; then
|
|
||||||
read "$1" <<< "$cmd"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
echo -e "\e[1;31mERROR\e[0m: Command '$2' not found"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoption() {
|
|
||||||
# $1 = arg
|
|
||||||
option="${1#*--}"
|
|
||||||
action="${option%%-*}"
|
|
||||||
name="${option#*-}"
|
|
||||||
if [ ${default_options[$name]+_} ]; then
|
|
||||||
case "$action" in
|
|
||||||
enable) meson_options[$name]=true;;
|
|
||||||
disable) meson_options[$name]=false;;
|
|
||||||
*) echo -e "\e[1;33mINFO\e[0m: Ignoring unknown action '$action'";;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
echo -e "\e[1;33mINFO\e[0m: Ignoring unknown option '$option'"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echooption() {
|
|
||||||
# $1 = option
|
|
||||||
if [ ${meson_options[$1]+_} ]; then
|
|
||||||
echo ${meson_options[$1]}
|
|
||||||
elif [ ${default_options[$1]+_} ]; then
|
|
||||||
echo ${default_options[$1]}
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitycheck MESON 'meson'
|
|
||||||
sanitycheck MESONTEST 'mesontest'
|
|
||||||
sanitycheck NINJA 'ninja' 'ninja-build'
|
|
||||||
|
|
||||||
declare -A meson_options
|
|
||||||
|
|
||||||
while (($# > 0)); do
|
|
||||||
case "${1%%=*}" in
|
|
||||||
--prefix) read_arg prefix "$@" || shift;;
|
|
||||||
--bindir) read_arg bindir "$@" || shift;;
|
|
||||||
--sbindir) read_arg sbindir "$@" || shift;;
|
|
||||||
--libexecdir) read_arg libexecdir "$@" || shift;;
|
|
||||||
--datarootdir) read_arg datarootdir "$@" || shift;;
|
|
||||||
--datadir) read_arg datadir "$@" || shift;;
|
|
||||||
--sysconfdir) read_arg sysconfdir "$@" || shift;;
|
|
||||||
--libdir) read_arg libdir "$@" || shift;;
|
|
||||||
--mandir) read_arg mandir "$@" || shift;;
|
|
||||||
--includedir) read_arg includedir "$@" || shift;;
|
|
||||||
*) checkoption $1;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# Defaults
|
|
||||||
test -z ${prefix} && prefix="/usr/local"
|
|
||||||
test -z ${bindir} && bindir=${prefix}/bin
|
|
||||||
test -z ${sbindir} && sbindir=${prefix}/sbin
|
|
||||||
test -z ${libexecdir} && libexecdir=${prefix}/bin
|
|
||||||
test -z ${datarootdir} && datarootdir=${prefix}/share
|
|
||||||
test -z ${datadir} && datadir=${datarootdir}
|
|
||||||
test -z ${sysconfdir} && sysconfdir=${prefix}/etc
|
|
||||||
test -z ${libdir} && libdir=${prefix}/lib
|
|
||||||
test -z ${mandir} && mandir=${prefix}/share/man
|
|
||||||
test -z ${includedir} && includedir=${prefix}/include
|
|
||||||
|
|
||||||
# The source directory is the location of this file
|
|
||||||
srcdir=$(dirname $0)
|
|
||||||
|
|
||||||
# The build directory is the current location
|
|
||||||
builddir=`pwd`
|
|
||||||
|
|
||||||
# If we're calling this file from the source directory then
|
|
||||||
# we automatically create a build directory and ensure that
|
|
||||||
# both Meson and Ninja invocations are relative to that
|
|
||||||
# location
|
|
||||||
if [[ -f "${builddir}/meson.build" ]]; then
|
|
||||||
mkdir -p _build
|
|
||||||
builddir="${builddir}/_build"
|
|
||||||
NINJA_OPT="-C ${builddir}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wrapper Makefile for Ninja
|
|
||||||
cat > Makefile <<END
|
|
||||||
# Generated by configure; do not edit
|
|
||||||
|
|
||||||
all: rebuild
|
|
||||||
${NINJA} ${NINJA_OPT}
|
|
||||||
|
|
||||||
rebuild:
|
|
||||||
rm -f ${builddir}/hammond
|
|
||||||
|
|
||||||
install:
|
|
||||||
DESTDIR="\$(DESTDIR)" ${NINJA} ${NINJA_OPT} install
|
|
||||||
|
|
||||||
uninstall:
|
|
||||||
${NINJA} ${NINJA_OPT} uninstall
|
|
||||||
|
|
||||||
release:
|
|
||||||
${NINJA} ${NINJA_OPT} release
|
|
||||||
|
|
||||||
check:
|
|
||||||
${MESONTEST} ${NINJA_OPT}
|
|
||||||
END
|
|
||||||
|
|
||||||
echo "
|
|
||||||
|
|
||||||
hammond
|
|
||||||
=======
|
|
||||||
|
|
||||||
meson: ${MESON}
|
|
||||||
ninja: ${NINJA}
|
|
||||||
prefix: ${prefix}
|
|
||||||
|
|
||||||
Now type 'make' to build
|
|
||||||
"
|
|
||||||
|
|
||||||
cmd_options=""
|
|
||||||
for key in "${!meson_options[@]}"; do
|
|
||||||
cmd_options="$cmd_options -Denable-$key=${meson_options[$key]}"
|
|
||||||
done
|
|
||||||
|
|
||||||
exec ${MESON} \
|
|
||||||
--prefix=${prefix} \
|
|
||||||
--libdir=${libdir} \
|
|
||||||
--libexecdir=${libexecdir} \
|
|
||||||
--datadir=${datadir} \
|
|
||||||
--sysconfdir=${sysconfdir} \
|
|
||||||
--bindir=${bindir} \
|
|
||||||
--includedir=${includedir} \
|
|
||||||
--mandir=${mandir} \
|
|
||||||
${cmd_options} \
|
|
||||||
${builddir} \
|
|
||||||
${srcdir}
|
|
||||||
+18
-18
@@ -5,41 +5,41 @@ version = "0.1.0"
|
|||||||
workspace = "../"
|
workspace = "../"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ammonia = "1.0.1"
|
ammonia = "1.1.0"
|
||||||
chrono = "0.4.0"
|
chrono = "0.4.2"
|
||||||
derive_builder = "0.5.1"
|
derive_builder = "0.5.1"
|
||||||
dotenv = "0.10.1"
|
|
||||||
error-chain = "0.11.0"
|
|
||||||
itertools = "0.7.6"
|
|
||||||
lazy_static = "1.0.0"
|
lazy_static = "1.0.0"
|
||||||
log = "0.4.1"
|
log = "0.4.1"
|
||||||
rayon = "0.9.0"
|
rayon = "1.0.1"
|
||||||
|
rayon-futures = "0.1.0"
|
||||||
rfc822_sanitizer = "0.3.3"
|
rfc822_sanitizer = "0.3.3"
|
||||||
rss = "1.2.1"
|
rss = "1.5.0"
|
||||||
url = "1.6.0"
|
url = "1.7.0"
|
||||||
xdg = "2.1.0"
|
xdg = "2.1.0"
|
||||||
futures = "0.1.18"
|
xml-rs = "0.8.0"
|
||||||
hyper = "0.11.18"
|
futures = "0.1.21"
|
||||||
tokio-core = "0.1.12"
|
hyper = "0.11.27"
|
||||||
hyper-tls = "0.1.2"
|
tokio-core = "0.1.17"
|
||||||
|
hyper-tls = "0.1.3"
|
||||||
native-tls = "0.1.5"
|
native-tls = "0.1.5"
|
||||||
futures-cpupool = "0.1.8"
|
|
||||||
num_cpus = "1.8.0"
|
num_cpus = "1.8.0"
|
||||||
failure = "0.1.1"
|
failure = "0.1.1"
|
||||||
failure_derive = "0.1.1"
|
failure_derive = "0.1.1"
|
||||||
|
|
||||||
[dependencies.diesel]
|
[dependencies.diesel]
|
||||||
features = ["sqlite", "r2d2"]
|
features = ["sqlite", "r2d2"]
|
||||||
version = "1.1.1"
|
version = "= 1.2.2"
|
||||||
|
|
||||||
[dependencies.diesel_migrations]
|
[dependencies.diesel_migrations]
|
||||||
features = ["sqlite"]
|
features = ["sqlite"]
|
||||||
version = "1.1.0"
|
version = "= 1.2.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rand = "0.4.2"
|
rand = "0.5.0"
|
||||||
tempdir = "0.3.6"
|
tempdir = "0.3.7"
|
||||||
criterion = "0.2.0"
|
criterion = "0.2.3"
|
||||||
|
pretty_assertions = "0.5.1"
|
||||||
|
maplit = "1.0.1"
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "bench"
|
name = "bench"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
#![allow(unused)]
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate criterion;
|
extern crate criterion;
|
||||||
use criterion::Criterion;
|
use criterion::Criterion;
|
||||||
|
|
||||||
// extern crate futures;
|
// extern crate futures;
|
||||||
// extern crate futures_cpupool;
|
|
||||||
extern crate hammond_data;
|
extern crate hammond_data;
|
||||||
extern crate hyper;
|
extern crate hyper;
|
||||||
extern crate hyper_tls;
|
extern crate hyper_tls;
|
||||||
@@ -15,13 +16,12 @@ extern crate rss;
|
|||||||
// use rayon::prelude::*;
|
// use rayon::prelude::*;
|
||||||
|
|
||||||
// use futures::future::*;
|
// use futures::future::*;
|
||||||
// use futures_cpupool::CpuPool;
|
|
||||||
use tokio_core::reactor::Core;
|
use tokio_core::reactor::Core;
|
||||||
|
|
||||||
use hammond_data::FeedBuilder;
|
|
||||||
use hammond_data::Source;
|
|
||||||
use hammond_data::database::truncate_db;
|
use hammond_data::database::truncate_db;
|
||||||
use hammond_data::pipeline;
|
use hammond_data::pipeline;
|
||||||
|
use hammond_data::FeedBuilder;
|
||||||
|
use hammond_data::Source;
|
||||||
// use hammond_data::errors::*;
|
// use hammond_data::errors::*;
|
||||||
|
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
@@ -58,22 +58,6 @@ static FEEDS: &[(&[u8], &str)] = &[
|
|||||||
(STARS, STARS_URL),
|
(STARS, STARS_URL),
|
||||||
];
|
];
|
||||||
|
|
||||||
// This is broken and I don't know why.
|
|
||||||
fn bench_pipeline(c: &mut Criterion) {
|
|
||||||
truncate_db().unwrap();
|
|
||||||
FEEDS.iter().for_each(|&(_, url)| {
|
|
||||||
Source::from_url(url).unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
c.bench_function("pipline", move |b| {
|
|
||||||
b.iter(|| {
|
|
||||||
let sources = hammond_data::dbqueries::get_sources().unwrap();
|
|
||||||
pipeline::run(sources, true).unwrap();
|
|
||||||
})
|
|
||||||
});
|
|
||||||
truncate_db().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_index_large_feed(c: &mut Criterion) {
|
fn bench_index_large_feed(c: &mut Criterion) {
|
||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
let url = "https://www.greaterthancode.com/feed/podcast";
|
let url = "https://www.greaterthancode.com/feed/podcast";
|
||||||
@@ -116,10 +100,5 @@ fn bench_index_small_feed(c: &mut Criterion) {
|
|||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
criterion_group!(
|
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed);
|
||||||
benches,
|
|
||||||
bench_pipeline,
|
|
||||||
bench_index_large_feed,
|
|
||||||
bench_index_small_feed
|
|
||||||
);
|
|
||||||
criterion_main!(benches);
|
criterion_main!(benches);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Database Setup. This is only public to help with some unit tests.
|
//! 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::prelude::*;
|
||||||
use diesel::r2d2;
|
use diesel::r2d2;
|
||||||
@@ -16,7 +18,7 @@ type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
|
|||||||
|
|
||||||
embed_migrations!("migrations/");
|
embed_migrations!("migrations/");
|
||||||
|
|
||||||
lazy_static!{
|
lazy_static! {
|
||||||
static ref POOL: Pool = init_pool(DB_PATH.to_str().unwrap());
|
static ref POOL: Pool = init_pool(DB_PATH.to_str().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +32,7 @@ extern crate tempdir;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref TEMPDIR: tempdir::TempDir = {
|
static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("hammond_unit_test").unwrap() };
|
||||||
tempdir::TempDir::new("hammond_unit_test").unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
|
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+131
-17
@@ -5,6 +5,7 @@ use diesel::prelude::*;
|
|||||||
|
|
||||||
use diesel;
|
use diesel;
|
||||||
use diesel::dsl::exists;
|
use diesel::dsl::exists;
|
||||||
|
use diesel::query_builder::AsQuery;
|
||||||
use diesel::select;
|
use diesel::select;
|
||||||
|
|
||||||
use database::connection;
|
use database::connection;
|
||||||
@@ -33,6 +34,18 @@ pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError> {
|
||||||
|
use schema::podcast::dsl::*;
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
podcast
|
||||||
|
.order(title.asc())
|
||||||
|
.filter(id.ne_all(filter_ids))
|
||||||
|
.load::<Podcast>(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
|
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -90,6 +103,20 @@ pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetQuery, DataError> {
|
||||||
|
use schema::episode::dsl::*;
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
episode
|
||||||
|
.select((
|
||||||
|
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||||
|
))
|
||||||
|
.filter(rowid.eq(ep_id))
|
||||||
|
.get_result::<EpisodeWidgetQuery>(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
|
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -102,24 +129,33 @@ pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataE
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_episodes_widgets_with_limit(limit: u32) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
pub fn get_episode_description(rowid_: i32) -> Result<Option<String>, DataError> {
|
||||||
use schema::episode;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
let con = db.get()?;
|
let con = db.get()?;
|
||||||
|
|
||||||
episode::table
|
episode
|
||||||
.select((
|
.filter(rowid.eq(rowid_))
|
||||||
episode::rowid,
|
.select(description)
|
||||||
episode::title,
|
.get_result::<Option<String>>(&con)
|
||||||
episode::uri,
|
.map_err(From::from)
|
||||||
episode::local_uri,
|
}
|
||||||
episode::epoch,
|
|
||||||
episode::length,
|
pub fn get_episodes_widgets_filter_limit(
|
||||||
episode::duration,
|
filter_ids: &[i32],
|
||||||
episode::played,
|
limit: u32,
|
||||||
episode::podcast_id,
|
) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||||
))
|
use schema::episode::dsl::*;
|
||||||
.order(episode::epoch.desc())
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
let columns = (
|
||||||
|
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
episode
|
||||||
|
.select(columns)
|
||||||
|
.order(epoch.desc())
|
||||||
|
.filter(podcast_id.ne_all(filter_ids))
|
||||||
.limit(i64::from(limit))
|
.limit(i64::from(limit))
|
||||||
.load::<EpisodeWidgetQuery>(&con)
|
.load::<EpisodeWidgetQuery>(&con)
|
||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
@@ -159,14 +195,27 @@ pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
Episode::belonging_to(parent)
|
||||||
|
.count()
|
||||||
|
.get_result(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
let con = db.get()?;
|
let con = db.get()?;
|
||||||
|
let columns = (
|
||||||
|
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
|
||||||
|
);
|
||||||
|
|
||||||
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
|
episode
|
||||||
|
.select(columns)
|
||||||
.filter(podcast_id.eq(parent.id()))
|
.filter(podcast_id.eq(parent.id()))
|
||||||
// .group_by(epoch)
|
|
||||||
.order(epoch.desc())
|
.order(epoch.desc())
|
||||||
.load::<EpisodeWidgetQuery>(&con)
|
.load::<EpisodeWidgetQuery>(&con)
|
||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
@@ -323,6 +372,45 @@ pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, Dat
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn episode_had_description(rowid_: i32) -> Result<bool, DataError> {
|
||||||
|
use schema::episode::dsl::*;
|
||||||
|
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
select(exists(episode.filter(rowid.eq(rowid_))))
|
||||||
|
.get_result(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the `episode` table contains any rows
|
||||||
|
///
|
||||||
|
/// Return true if `episode` table is populated.
|
||||||
|
pub fn is_episodes_populated() -> Result<bool, DataError> {
|
||||||
|
use schema::episode::dsl::*;
|
||||||
|
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
select(exists(episode.as_query()))
|
||||||
|
.get_result(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the `podcast` table contains any rows
|
||||||
|
///
|
||||||
|
/// Return true if `podcast table is populated.
|
||||||
|
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
|
||||||
|
use schema::podcast::dsl::*;
|
||||||
|
|
||||||
|
let db = connection();
|
||||||
|
let con = db.get()?;
|
||||||
|
|
||||||
|
select(exists(podcast.filter(id.ne_all(filter_ids))))
|
||||||
|
.get_result(&con)
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
|
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -348,3 +436,29 @@ pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use database::*;
|
||||||
|
use pipeline;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_none_to_played_now() {
|
||||||
|
truncate_db().unwrap();
|
||||||
|
|
||||||
|
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||||
|
com/InterceptedWithJeremyScahill";
|
||||||
|
let source = Source::from_url(url).unwrap();
|
||||||
|
let id = source.id();
|
||||||
|
pipeline::run(vec![source], true).unwrap();
|
||||||
|
let pd = get_podcast_from_source_id(id).unwrap();
|
||||||
|
|
||||||
|
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
|
||||||
|
assert_ne!(eps_num, 0);
|
||||||
|
|
||||||
|
update_none_to_played_now(&pd).unwrap();
|
||||||
|
let eps_num2 = get_pd_unplayed_episodes(&pd).unwrap().len();
|
||||||
|
assert_eq!(eps_num2, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-16
@@ -3,16 +3,31 @@ use diesel::r2d2;
|
|||||||
use diesel_migrations::RunMigrationsError;
|
use diesel_migrations::RunMigrationsError;
|
||||||
use hyper;
|
use hyper;
|
||||||
use native_tls;
|
use native_tls;
|
||||||
// use rss;
|
use rss;
|
||||||
use url;
|
use url;
|
||||||
|
use xml;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
// use std::fmt;
|
|
||||||
|
|
||||||
// fadsadfs NOT SYNC
|
use models::Source;
|
||||||
// #[derive(Fail, Debug)]
|
|
||||||
// #[fail(display = "RSS Error: {}", _0)]
|
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
|
||||||
// struct RSSError(rss::Error);
|
#[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)]
|
#[derive(Fail, Debug)]
|
||||||
pub enum DataError {
|
pub enum DataError {
|
||||||
@@ -34,20 +49,17 @@ pub enum DataError {
|
|||||||
#[fail(display = "IO Error: {}", _0)]
|
#[fail(display = "IO Error: {}", _0)]
|
||||||
IOError(#[cause] io::Error),
|
IOError(#[cause] io::Error),
|
||||||
#[fail(display = "RSS Error: {}", _0)]
|
#[fail(display = "RSS Error: {}", _0)]
|
||||||
// Rss::Error is not yet Sync
|
RssError(#[cause] rss::Error),
|
||||||
RssCrateError(String),
|
#[fail(display = "XML Reader Error: {}", _0)]
|
||||||
|
XmlReaderError(#[cause] xml::reader::Error),
|
||||||
#[fail(display = "Error: {}", _0)]
|
#[fail(display = "Error: {}", _0)]
|
||||||
Bail(String),
|
Bail(String),
|
||||||
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
|
#[fail(display = "{}", _0)]
|
||||||
HttpStatusError {
|
HttpStatusGeneral(HttpStatusError),
|
||||||
url: String,
|
#[fail(display = "FIXME: This should be better")]
|
||||||
status_code: hyper::StatusCode,
|
F301(Source),
|
||||||
context: String,
|
|
||||||
},
|
|
||||||
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
|
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
|
||||||
ParseEpisodeError { reason: String, parent_id: i32 },
|
ParseEpisodeError { reason: String, parent_id: i32 },
|
||||||
#[fail(display = "No Futures where produced to be run.")]
|
|
||||||
EmptyFuturesList,
|
|
||||||
#[fail(display = "Episode was not changed and thus skipped.")]
|
#[fail(display = "Episode was not changed and thus skipped.")]
|
||||||
EpisodeNotChanged,
|
EpisodeNotChanged,
|
||||||
}
|
}
|
||||||
@@ -100,6 +112,18 @@ impl From<io::Error> for DataError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
impl From<String> for DataError {
|
||||||
fn from(err: String) -> Self {
|
fn from(err: String) -> Self {
|
||||||
DataError::Bail(err)
|
DataError::Bail(err)
|
||||||
|
|||||||
+96
-105
@@ -1,16 +1,15 @@
|
|||||||
|
#![cfg_attr(feature = "cargo-clippy", allow(unit_arg))]
|
||||||
//! Index Feeds.
|
//! Index Feeds.
|
||||||
|
|
||||||
use futures::future::*;
|
use futures::future::*;
|
||||||
use itertools::{Either, Itertools};
|
use futures::prelude::*;
|
||||||
|
use futures::stream;
|
||||||
use rss;
|
use rss;
|
||||||
|
|
||||||
use dbqueries;
|
use dbqueries;
|
||||||
use errors::DataError;
|
use errors::DataError;
|
||||||
use models::{Index, IndexState, Update};
|
use models::{Index, IndexState, Update};
|
||||||
use models::{NewEpisode, NewPodcast, Podcast};
|
use models::{NewEpisode, NewEpisodeMinimal, NewPodcast, Podcast};
|
||||||
use pipeline::*;
|
|
||||||
|
|
||||||
type InsertUpdate = (Vec<NewEpisode>, Vec<Option<(NewEpisode, i32)>>);
|
|
||||||
|
|
||||||
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
|
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
|
||||||
/// that corresponds to the `Source.uri` field.
|
/// that corresponds to the `Source.uri` field.
|
||||||
@@ -26,90 +25,104 @@ pub struct Feed {
|
|||||||
|
|
||||||
impl Feed {
|
impl Feed {
|
||||||
/// Index the contents of the RSS `Feed` into the database.
|
/// Index the contents of the RSS `Feed` into the database.
|
||||||
pub fn index(self) -> Box<Future<Item = (), Error = DataError> + Send> {
|
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send {
|
||||||
let fut = self.parse_podcast_async()
|
self.parse_podcast_async()
|
||||||
.and_then(|pd| pd.to_podcast())
|
.and_then(|pd| pd.to_podcast())
|
||||||
.and_then(move |pd| self.index_channel_items(&pd));
|
.and_then(move |pd| self.index_channel_items(pd))
|
||||||
|
|
||||||
Box::new(fut)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_podcast(&self) -> NewPodcast {
|
fn parse_podcast(&self) -> NewPodcast {
|
||||||
NewPodcast::new(&self.channel, self.source_id)
|
NewPodcast::new(&self.channel, self.source_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_podcast_async(&self) -> Box<Future<Item = NewPodcast, Error = DataError> + Send> {
|
fn parse_podcast_async(&self) -> impl Future<Item = NewPodcast, Error = DataError> + Send {
|
||||||
Box::new(ok(self.parse_podcast()))
|
ok(self.parse_podcast())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn index_channel_items(
|
fn index_channel_items(self, pd: Podcast) -> impl Future<Item = (), Error = DataError> + Send {
|
||||||
&self,
|
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
|
||||||
pd: &Podcast,
|
|
||||||
) -> Box<Future<Item = (), Error = DataError> + Send> {
|
// Parse the episodes
|
||||||
let fut = self.get_stuff(pd)
|
let episodes = stream.filter_map(move |item| {
|
||||||
.and_then(|(insert, update)| {
|
glue(&item, pd.id())
|
||||||
if !insert.is_empty() {
|
.map_err(|err| error!("Failed to parse an episode: {}", err))
|
||||||
info!("Indexing {} episodes.", insert.len());
|
.ok()
|
||||||
if let Err(err) = dbqueries::index_new_episodes(insert.as_slice()) {
|
});
|
||||||
error!("Failed batch indexng, Fallign back to individual indexing.");
|
|
||||||
error!("{}", err);
|
// Filter errors, Index updatable episodes, return insertables.
|
||||||
insert.iter().for_each(|ep| {
|
filter_episodes(episodes)
|
||||||
if let Err(err) = ep.index() {
|
// Batch index insertable episodes.
|
||||||
error!("Failed to index episode: {:?}.", ep.title());
|
.and_then(|eps| ok(batch_insert_episodes(&eps)))
|
||||||
error!("{}", err);
|
}
|
||||||
};
|
}
|
||||||
})
|
|
||||||
}
|
fn glue(item: &rss::Item, id: i32) -> Result<IndexState<NewEpisode>, DataError> {
|
||||||
}
|
NewEpisodeMinimal::new(item, id).and_then(move |ep| determine_ep_state(ep, item))
|
||||||
Ok((insert, update))
|
}
|
||||||
})
|
|
||||||
.map(|(_, update)| {
|
fn determine_ep_state(
|
||||||
if !update.is_empty() {
|
ep: NewEpisodeMinimal,
|
||||||
info!("Updating {} episodes.", update.len());
|
item: &rss::Item,
|
||||||
// see get_stuff for more
|
) -> Result<IndexState<NewEpisode>, DataError> {
|
||||||
update
|
// Check if feed exists
|
||||||
.into_iter()
|
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
|
||||||
.filter_map(|x| x)
|
|
||||||
.for_each(|(ref ep, rowid)| {
|
if !exists {
|
||||||
if let Err(err) = ep.update(rowid) {
|
Ok(IndexState::Index(ep.into_new_episode(item)))
|
||||||
error!("Failed to index episode: {:?}.", ep.title());
|
} else {
|
||||||
error!("{}", err);
|
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
|
||||||
};
|
let rowid = old.rowid();
|
||||||
})
|
|
||||||
}
|
if ep != old {
|
||||||
|
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
|
||||||
|
} else {
|
||||||
|
Ok(IndexState::NotChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_episodes<'a, S>(
|
||||||
|
stream: S,
|
||||||
|
) -> impl Future<Item = Vec<NewEpisode>, Error = DataError> + Send + 'a
|
||||||
|
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();
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
IndexState::Index(s) => Some(s),
|
||||||
|
})
|
||||||
|
// only Index is left, collect them for batch index
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_insert_episodes(episodes: &[NewEpisode]) {
|
||||||
|
if episodes.is_empty() {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Indexing {} episodes.", episodes.len());
|
||||||
|
dbqueries::index_new_episodes(episodes)
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("Failed batch indexng: {}", err);
|
||||||
|
info!("Fallign back to individual indexing.");
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
episodes.iter().for_each(|ep| {
|
||||||
|
ep.index()
|
||||||
|
.map_err(|err| error!("Error: {}.", err))
|
||||||
|
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
|
})
|
||||||
Box::new(fut)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_stuff(
|
|
||||||
&self,
|
|
||||||
pd: &Podcast,
|
|
||||||
) -> Box<Future<Item = InsertUpdate, Error = DataError> + Send> {
|
|
||||||
let (insert, update): (Vec<_>, Vec<_>) = self.channel
|
|
||||||
.items()
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| glue_async(item, pd.id()))
|
|
||||||
// This is sort of ugly but I think it's cheaper than pushing None
|
|
||||||
// to updated and filtering it out later.
|
|
||||||
// Even though we already map_filter in index_channel_items.
|
|
||||||
// I am not sure what the optimizations are on match vs allocating None.
|
|
||||||
.map(|fut| {
|
|
||||||
fut.and_then(|x| match x {
|
|
||||||
IndexState::NotChanged => return Err(DataError::EpisodeNotChanged),
|
|
||||||
_ => Ok(x),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.flat_map(|fut| fut.wait())
|
|
||||||
.partition_map(|state| match state {
|
|
||||||
IndexState::Index(e) => Either::Left(e),
|
|
||||||
IndexState::Update(e) => Either::Right(Some(e)),
|
|
||||||
// This should never occur
|
|
||||||
IndexState::NotChanged => Either::Right(None),
|
|
||||||
});
|
|
||||||
|
|
||||||
Box::new(ok((insert, update)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -117,10 +130,10 @@ mod tests {
|
|||||||
use rss::Channel;
|
use rss::Channel;
|
||||||
use tokio_core::reactor::Core;
|
use tokio_core::reactor::Core;
|
||||||
|
|
||||||
use Source;
|
|
||||||
use database::truncate_db;
|
use database::truncate_db;
|
||||||
use dbqueries;
|
use dbqueries;
|
||||||
use utils::get_feed;
|
use utils::get_feed;
|
||||||
|
use Source;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
@@ -161,7 +174,8 @@ mod tests {
|
|||||||
fn test_complete_index() {
|
fn test_complete_index() {
|
||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
|
|
||||||
let feeds: Vec<_> = URLS.iter()
|
let feeds: Vec<_> = URLS
|
||||||
|
.iter()
|
||||||
.map(|&(path, url)| {
|
.map(|&(path, url)| {
|
||||||
// Create and insert a Source into db
|
// Create and insert a Source into db
|
||||||
let s = Source::from_url(url).unwrap();
|
let s = Source::from_url(url).unwrap();
|
||||||
@@ -202,31 +216,8 @@ mod tests {
|
|||||||
let feed = get_feed(path, 42);
|
let feed = get_feed(path, 42);
|
||||||
let pd = feed.parse_podcast().to_podcast().unwrap();
|
let pd = feed.parse_podcast().to_podcast().unwrap();
|
||||||
|
|
||||||
feed.index_channel_items(&pd).wait().unwrap();
|
feed.index_channel_items(pd).wait().unwrap();
|
||||||
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
|
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
|
||||||
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
|
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_feed_get_stuff() {
|
|
||||||
truncate_db().unwrap();
|
|
||||||
|
|
||||||
let path = "tests/feeds/2018-01-20-Intercepted.xml";
|
|
||||||
let feed = get_feed(path, 42);
|
|
||||||
let pd = feed.parse_podcast().to_podcast().unwrap();
|
|
||||||
|
|
||||||
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
|
|
||||||
assert_eq!(43, insert.len());
|
|
||||||
assert_eq!(0, update.len());
|
|
||||||
|
|
||||||
feed.index().wait().unwrap();
|
|
||||||
|
|
||||||
let path = "tests/feeds/2018-02-03-Intercepted.xml";
|
|
||||||
let feed = get_feed(path, 42);
|
|
||||||
let pd = feed.parse_podcast().to_podcast().unwrap();
|
|
||||||
|
|
||||||
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
|
|
||||||
assert_eq!(4, insert.len());
|
|
||||||
assert_eq!(43, update.len());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-24
@@ -1,25 +1,36 @@
|
|||||||
#![recursion_limit = "1024"]
|
#![recursion_limit = "1024"]
|
||||||
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
|
|
||||||
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
|
|
||||||
#![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))]
|
|
||||||
#![allow(unknown_lints)]
|
#![allow(unknown_lints)]
|
||||||
#![deny(bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
|
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
|
||||||
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals,
|
#![cfg_attr(feature = "cargo-clippy", allow(option_map_unit_fn))]
|
||||||
path_statements, patterns_in_fns_without_body, plugin_as_library, private_in_public,
|
#![cfg_attr(
|
||||||
private_no_mangle_fns, private_no_mangle_statics, safe_extern_statics,
|
feature = "clippy",
|
||||||
unconditional_recursion, unions_with_drop_fields, unused_allocation, unused_comparisons,
|
warn(
|
||||||
unused_parens, while_true)]
|
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut,
|
||||||
#![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts)]
|
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else,
|
||||||
#![deny(unused_extern_crates, unused)]
|
items_after_statements, used_underscore_binding
|
||||||
|
)
|
||||||
// #![feature(conservative_impl_trait)]
|
)]
|
||||||
|
#![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
|
//! FIXME: Docs
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[macro_use]
|
||||||
|
extern crate pretty_assertions;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[macro_use]
|
||||||
|
extern crate maplit;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate derive_builder;
|
extern crate derive_builder;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
@@ -38,34 +49,39 @@ extern crate log;
|
|||||||
extern crate ammonia;
|
extern crate ammonia;
|
||||||
extern crate chrono;
|
extern crate chrono;
|
||||||
extern crate futures;
|
extern crate futures;
|
||||||
extern crate futures_cpupool;
|
|
||||||
extern crate hyper;
|
extern crate hyper;
|
||||||
extern crate hyper_tls;
|
extern crate hyper_tls;
|
||||||
extern crate itertools;
|
|
||||||
extern crate native_tls;
|
extern crate native_tls;
|
||||||
extern crate num_cpus;
|
extern crate num_cpus;
|
||||||
extern crate rayon;
|
extern crate rayon;
|
||||||
|
extern crate rayon_futures;
|
||||||
extern crate rfc822_sanitizer;
|
extern crate rfc822_sanitizer;
|
||||||
extern crate rss;
|
extern crate rss;
|
||||||
extern crate tokio_core;
|
extern crate tokio_core;
|
||||||
extern crate url;
|
extern crate url;
|
||||||
extern crate xdg;
|
extern crate xdg;
|
||||||
|
extern crate xml;
|
||||||
|
|
||||||
|
pub mod database;
|
||||||
#[allow(missing_docs)]
|
#[allow(missing_docs)]
|
||||||
pub mod dbqueries;
|
pub mod dbqueries;
|
||||||
#[allow(missing_docs)]
|
#[allow(missing_docs)]
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod utils;
|
|
||||||
pub mod database;
|
|
||||||
pub mod pipeline;
|
|
||||||
pub(crate) mod models;
|
|
||||||
mod feed;
|
mod feed;
|
||||||
|
pub(crate) mod models;
|
||||||
|
pub mod opml;
|
||||||
mod parser;
|
mod parser;
|
||||||
|
pub mod pipeline;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
pub use feed::{Feed, FeedBuilder};
|
pub use feed::{Feed, FeedBuilder};
|
||||||
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
|
|
||||||
pub use models::Save;
|
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.
|
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
|
||||||
#[allow(missing_debug_implementations)]
|
#[allow(missing_debug_implementations)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use diesel;
|
use diesel;
|
||||||
use diesel::SaveChangesDsl;
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::SaveChangesDsl;
|
||||||
|
|
||||||
use database::connection;
|
use database::connection;
|
||||||
use errors::DataError;
|
use errors::DataError;
|
||||||
@@ -31,9 +31,12 @@ pub struct Episode {
|
|||||||
podcast_id: i32,
|
podcast_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Save<Episode, DataError> for Episode {
|
impl Save<Episode> for Episode {
|
||||||
/// Helper method to easily save/"sync" current state of self to the Database.
|
type Error = DataError;
|
||||||
fn save(&self) -> Result<Episode, DataError> {
|
|
||||||
|
/// Helper method to easily save/"sync" current state of self to the
|
||||||
|
/// Database.
|
||||||
|
fn save(&self) -> Result<Episode, Self::Error> {
|
||||||
let db = connection();
|
let db = connection();
|
||||||
let tempdb = db.get()?;
|
let tempdb = db.get()?;
|
||||||
|
|
||||||
@@ -223,9 +226,12 @@ impl From<Episode> for EpisodeWidgetQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Save<usize, DataError> for EpisodeWidgetQuery {
|
impl Save<usize> for EpisodeWidgetQuery {
|
||||||
/// Helper method to easily save/"sync" current state of self to the Database.
|
type Error = DataError;
|
||||||
fn save(&self) -> Result<usize, 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 schema::episode::dsl::*;
|
||||||
|
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -361,9 +367,12 @@ pub struct EpisodeCleanerQuery {
|
|||||||
played: Option<i32>,
|
played: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Save<usize, DataError> for EpisodeCleanerQuery {
|
impl Save<usize> for EpisodeCleanerQuery {
|
||||||
/// Helper method to easily save/"sync" current state of self to the Database.
|
type Error = DataError;
|
||||||
fn save(&self) -> Result<usize, 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 schema::episode::dsl::*;
|
||||||
|
|
||||||
let db = connection();
|
let db = connection();
|
||||||
|
|||||||
@@ -30,21 +30,30 @@ pub enum IndexState<T> {
|
|||||||
NotChanged,
|
NotChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Insert<T, E> {
|
pub trait Insert<T> {
|
||||||
fn insert(&self) -> Result<T, E>;
|
type Error;
|
||||||
|
|
||||||
|
fn insert(&self) -> Result<T, Self::Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Update<T, E> {
|
pub trait Update<T> {
|
||||||
fn update(&self, i32) -> Result<T, E>;
|
type Error;
|
||||||
|
|
||||||
|
fn update(&self, i32) -> Result<T, Self::Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This might need to change in the future
|
// This might need to change in the future
|
||||||
pub trait Index<T, E>: Insert<T, E> + Update<T, E> {
|
pub trait Index<T>: Insert<T> + Update<T> {
|
||||||
fn index(&self) -> Result<T, E>;
|
type Error;
|
||||||
|
|
||||||
|
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FIXME: DOCS
|
/// FIXME: DOCS
|
||||||
pub trait Save<T, E> {
|
pub trait Save<T> {
|
||||||
/// Helper method to easily save/"sync" current state of a diesel model to the Database.
|
/// The Error type to be returned.
|
||||||
fn save(&self) -> Result<T, E>;
|
type Error;
|
||||||
|
/// Helper method to easily save/"sync" current state of a diesel model to
|
||||||
|
/// the Database.
|
||||||
|
fn save(&self) -> Result<T, Self::Error>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use errors::DataError;
|
|||||||
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
|
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
|
||||||
use parser;
|
use parser;
|
||||||
use schema::episode;
|
use schema::episode;
|
||||||
use utils::{replace_extra_spaces, url_cleaner};
|
use utils::url_cleaner;
|
||||||
|
|
||||||
#[derive(Insertable, AsChangeset)]
|
#[derive(Insertable, AsChangeset)]
|
||||||
#[table_name = "episode"]
|
#[table_name = "episode"]
|
||||||
@@ -43,7 +43,9 @@ impl From<NewEpisodeMinimal> for NewEpisode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Insert<(), DataError> for NewEpisode {
|
impl Insert<()> for NewEpisode {
|
||||||
|
type Error = DataError;
|
||||||
|
|
||||||
fn insert(&self) -> Result<(), DataError> {
|
fn insert(&self) -> Result<(), DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -58,7 +60,9 @@ impl Insert<(), DataError> for NewEpisode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Update<(), DataError> for NewEpisode {
|
impl Update<()> for NewEpisode {
|
||||||
|
type Error = DataError;
|
||||||
|
|
||||||
fn update(&self, episode_id: i32) -> Result<(), DataError> {
|
fn update(&self, episode_id: i32) -> Result<(), DataError> {
|
||||||
use schema::episode::dsl::*;
|
use schema::episode::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
@@ -73,8 +77,11 @@ impl Update<(), DataError> for NewEpisode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Index<(), DataError> for NewEpisode {
|
impl Index<()> for NewEpisode {
|
||||||
// Does not update the episode description if it's the only thing that has changed.
|
type Error = DataError;
|
||||||
|
|
||||||
|
// Does not update the episode description if it's the only thing that has
|
||||||
|
// changed.
|
||||||
fn index(&self) -> Result<(), DataError> {
|
fn index(&self) -> Result<(), DataError> {
|
||||||
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
|
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
|
||||||
|
|
||||||
@@ -94,17 +101,23 @@ impl Index<(), DataError> for NewEpisode {
|
|||||||
|
|
||||||
impl PartialEq<EpisodeMinimal> for NewEpisode {
|
impl PartialEq<EpisodeMinimal> for NewEpisode {
|
||||||
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
||||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
(self.title() == other.title())
|
||||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
&& (self.uri() == other.uri())
|
||||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
&& (self.duration() == other.duration())
|
||||||
|
&& (self.epoch() == other.epoch())
|
||||||
|
&& (self.guid() == other.guid())
|
||||||
|
&& (self.podcast_id() == other.podcast_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq<Episode> for NewEpisode {
|
impl PartialEq<Episode> for NewEpisode {
|
||||||
fn eq(&self, other: &Episode) -> bool {
|
fn eq(&self, other: &Episode) -> bool {
|
||||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
(self.title() == other.title())
|
||||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
&& (self.uri() == other.uri())
|
||||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
&& (self.duration() == other.duration())
|
||||||
|
&& (self.epoch() == other.epoch())
|
||||||
|
&& (self.guid() == other.guid())
|
||||||
|
&& (self.podcast_id() == other.podcast_id())
|
||||||
&& (self.description() == other.description())
|
&& (self.description() == other.description())
|
||||||
&& (self.length() == other.length())
|
&& (self.length() == other.length())
|
||||||
}
|
}
|
||||||
@@ -175,9 +188,12 @@ pub(crate) struct NewEpisodeMinimal {
|
|||||||
|
|
||||||
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
|
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
|
||||||
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
fn eq(&self, other: &EpisodeMinimal) -> bool {
|
||||||
(self.title() == other.title()) && (self.uri() == other.uri())
|
(self.title() == other.title())
|
||||||
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
|
&& (self.uri() == other.uri())
|
||||||
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
|
&& (self.duration() == other.duration())
|
||||||
|
&& (self.epoch() == other.epoch())
|
||||||
|
&& (self.guid() == other.guid())
|
||||||
|
&& (self.podcast_id() == other.podcast_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +201,7 @@ impl NewEpisodeMinimal {
|
|||||||
pub(crate) fn new(item: &rss::Item, parent_id: i32) -> Result<Self, DataError> {
|
pub(crate) fn new(item: &rss::Item, parent_id: i32) -> Result<Self, DataError> {
|
||||||
if item.title().is_none() {
|
if item.title().is_none() {
|
||||||
let err = DataError::ParseEpisodeError {
|
let err = DataError::ParseEpisodeError {
|
||||||
reason: format!("No title specified for this Episode."),
|
reason: "No title specified for this Episode.".into(),
|
||||||
parent_id,
|
parent_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,13 +211,16 @@ impl NewEpisodeMinimal {
|
|||||||
let title = item.title().unwrap().trim().to_owned();
|
let title = item.title().unwrap().trim().to_owned();
|
||||||
let guid = item.guid().map(|s| s.value().trim().to_owned());
|
let guid = item.guid().map(|s| s.value().trim().to_owned());
|
||||||
|
|
||||||
let uri = if let Some(url) = item.enclosure().map(|s| url_cleaner(s.url())) {
|
let uri = item.enclosure()
|
||||||
Some(url)
|
.map(|s| url_cleaner(s.url().trim()))
|
||||||
} else if item.link().is_some() {
|
// Fallback to Rss.Item.link if enclosure is None.
|
||||||
item.link().map(|s| url_cleaner(s))
|
.or_else(|| item.link().map(|s| url_cleaner(s.trim())));
|
||||||
} else {
|
|
||||||
|
// If url is still None return an Error as this behaviour is
|
||||||
|
// compliant with the RSS Spec.
|
||||||
|
if uri.is_none() {
|
||||||
let err = DataError::ParseEpisodeError {
|
let err = DataError::ParseEpisodeError {
|
||||||
reason: format!("No url specified for the item."),
|
reason: "No url specified for the item.".into(),
|
||||||
parent_id,
|
parent_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,7 +230,7 @@ impl NewEpisodeMinimal {
|
|||||||
// Default to rfc2822 represantation of epoch 0.
|
// Default to rfc2822 represantation of epoch 0.
|
||||||
let date = parse_rfc822(item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"));
|
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.
|
// Should treat information from the rss feeds as invalid by default.
|
||||||
// Case: Thu, 05 Aug 2016 06:00:00 -0400 <-- Actually that was friday.
|
// Case: "Thu, 05 Aug 2016 06:00:00 -0400" <-- Actually that was friday.
|
||||||
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
|
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
|
||||||
|
|
||||||
let duration = parser::parse_itunes_duration(item.itunes_ext());
|
let duration = parser::parse_itunes_duration(item.itunes_ext());
|
||||||
@@ -227,18 +246,17 @@ impl NewEpisodeMinimal {
|
|||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: TryInto is stabilizing in rustc v1.26!
|
||||||
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
|
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
|
||||||
let length = || -> Option<i32> { item.enclosure().map(|x| x.length().parse().ok())? }();
|
let length = item.enclosure().and_then(|x| x.length().parse().ok());
|
||||||
|
let description = item.description().and_then(|s| {
|
||||||
// Prefer itunes summary over rss.description since many feeds put html into
|
let sanitized_html = ammonia::Builder::new()
|
||||||
// rss.description.
|
// Remove `rel` attributes from `<a>` tags
|
||||||
let summary = item.itunes_ext().map(|s| s.summary()).and_then(|s| s);
|
.link_rel(None)
|
||||||
let description = if summary.is_some() {
|
.clean(s.trim())
|
||||||
summary.map(|s| replace_extra_spaces(&ammonia::clean(s)))
|
.to_string();
|
||||||
} else {
|
Some(sanitized_html)
|
||||||
item.description()
|
});
|
||||||
.map(|s| replace_extra_spaces(&ammonia::clean(s)))
|
|
||||||
};
|
|
||||||
|
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title(self.title)
|
.title(self.title)
|
||||||
@@ -284,8 +302,8 @@ impl NewEpisodeMinimal {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use database::truncate_db;
|
use database::truncate_db;
|
||||||
use dbqueries;
|
use dbqueries;
|
||||||
use models::*;
|
|
||||||
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
|
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
|
||||||
|
use models::*;
|
||||||
|
|
||||||
use rss::Channel;
|
use rss::Channel;
|
||||||
|
|
||||||
@@ -310,7 +328,6 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_MINIMAL_INTERCEPTED_2: NewEpisodeMinimal = {
|
static ref EXPECTED_MINIMAL_INTERCEPTED_2: NewEpisodeMinimal = {
|
||||||
NewEpisodeMinimalBuilder::default()
|
NewEpisodeMinimalBuilder::default()
|
||||||
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
|
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
|
||||||
@@ -324,13 +341,12 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_INTERCEPTED_1: NewEpisode = {
|
static ref EXPECTED_INTERCEPTED_1: NewEpisode = {
|
||||||
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data breach \
|
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data \
|
||||||
and allegations of Russian interference in the US election. Commentator \
|
breach and allegations of Russian interference in the US election. \
|
||||||
Shaun King explains his call for a boycott of the NFL and talks about his \
|
Commentator Shaun King explains his call for a boycott of the NFL and \
|
||||||
campaign to bring violent neo-Nazis to justice. Rapper Open Mike Eagle \
|
talks about his campaign to bring violent neo-Nazis to justice. Rapper \
|
||||||
performs.";
|
Open Mike Eagle performs.";
|
||||||
|
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title("The Super Bowl of Racism")
|
.title("The Super Bowl of Racism")
|
||||||
@@ -346,16 +362,15 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_INTERCEPTED_2: NewEpisode = {
|
static ref EXPECTED_INTERCEPTED_2: NewEpisode = {
|
||||||
let descr = "This week on Intercepted: Jeremy gives an update on the aftermath of \
|
let descr = "This week on Intercepted: Jeremy gives an update on the aftermath of \
|
||||||
Blackwater’s 2007 massacre of Iraqi civilians. Intercept reporter Lee Fang \
|
Blackwater’s 2007 massacre of Iraqi civilians. Intercept reporter Lee \
|
||||||
lays out how a network of libertarian think tanks called the Atlas Network \
|
Fang lays out how a network of libertarian think tanks called the Atlas \
|
||||||
is insidiously shaping political infrastructure in Latin America. We speak \
|
Network is insidiously shaping political infrastructure in Latin \
|
||||||
with attorney and former Hugo Chavez adviser Eva Golinger about the \
|
America. We speak with attorney and former Hugo Chavez adviser Eva \
|
||||||
Venezuela\'s political turmoil.And we hear Claudia Lizardo of the \
|
Golinger about the Venezuela\'s political turmoil.And we hear Claudia \
|
||||||
Caracas-based band, La Pequeña Revancha, talk about her music and hopes for \
|
Lizardo of the Caracas-based band, La Pequeña Revancha, talk about her \
|
||||||
Venezuela.";
|
music and hopes for Venezuela.";
|
||||||
|
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
|
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
|
||||||
@@ -371,7 +386,6 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref UPDATED_DURATION_INTERCEPTED_1: NewEpisode = {
|
static ref UPDATED_DURATION_INTERCEPTED_1: NewEpisode = {
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title("The Super Bowl of Racism")
|
.title("The Super Bowl of Racism")
|
||||||
@@ -387,7 +401,6 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_MINIMAL_LUP_1: NewEpisodeMinimal = {
|
static ref EXPECTED_MINIMAL_LUP_1: NewEpisodeMinimal = {
|
||||||
NewEpisodeMinimalBuilder::default()
|
NewEpisodeMinimalBuilder::default()
|
||||||
.title("Hacking Devices with Kali Linux | LUP 214")
|
.title("Hacking Devices with Kali Linux | LUP 214")
|
||||||
@@ -401,7 +414,6 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_MINIMAL_LUP_2: NewEpisodeMinimal = {
|
static ref EXPECTED_MINIMAL_LUP_2: NewEpisodeMinimal = {
|
||||||
NewEpisodeMinimalBuilder::default()
|
NewEpisodeMinimalBuilder::default()
|
||||||
.title("Gnome Does it Again | LUP 213")
|
.title("Gnome Does it Again | LUP 213")
|
||||||
@@ -415,12 +427,11 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_LUP_1: NewEpisode = {
|
static ref EXPECTED_LUP_1: NewEpisode = {
|
||||||
let descr = "Audit your network with a couple of easy commands on Kali Linux. Chris \
|
let descr = "Audit your network with a couple of easy commands on Kali Linux. Chris \
|
||||||
decides to blow off a little steam by attacking his IoT devices, Wes has the \
|
decides to blow off a little steam by attacking his IoT devices, Wes has \
|
||||||
scope on Equifax blaming open source & the Beard just saved the show. \
|
the scope on Equifax blaming open source & the Beard just saved the \
|
||||||
It’s a really packed episode!";
|
show. It’s a really packed episode!";
|
||||||
|
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title("Hacking Devices with Kali Linux | LUP 214")
|
.title("Hacking Devices with Kali Linux | LUP 214")
|
||||||
@@ -436,13 +447,13 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_LUP_2: NewEpisode = {
|
static ref EXPECTED_LUP_2: NewEpisode = {
|
||||||
let descr = "The Gnome project is about to solve one of our audience's biggest Wayland’s \
|
let descr =
|
||||||
concerns. But as the project takes on a new level of relevance, decisions for the \
|
"<p>The Gnome project is about to solve one of our audience's biggest Wayland’s \
|
||||||
next version of Gnome have us worried about the future.\nPlus we chat with Wimpy \
|
concerns. But as the project takes on a new level of relevance, decisions for \
|
||||||
about the Ubuntu Rally in NYC, Microsoft’s sneaky move to turn Windows 10 into the \
|
the next version of Gnome have us worried about the future.</p>\n\n<p>Plus we \
|
||||||
“ULTIMATE LINUX RUNTIME”, community news & more!";
|
chat with Wimpy about the Ubuntu Rally in NYC, Microsoft’s sneaky move to turn \
|
||||||
|
Windows 10 into the “ULTIMATE LINUX RUNTIME”, community news & more!</p>";
|
||||||
|
|
||||||
NewEpisodeBuilder::default()
|
NewEpisodeBuilder::default()
|
||||||
.title("Gnome Does it Again | LUP 213")
|
.title("Gnome Does it Again | LUP 213")
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
|
use ammonia;
|
||||||
use diesel;
|
use diesel;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use ammonia;
|
|
||||||
use rss;
|
use rss;
|
||||||
|
|
||||||
use errors::DataError;
|
use errors::DataError;
|
||||||
use models::{Index, Insert, Update};
|
|
||||||
use models::Podcast;
|
use models::Podcast;
|
||||||
|
use models::{Index, Insert, Update};
|
||||||
use schema::podcast;
|
use schema::podcast;
|
||||||
|
|
||||||
use database::connection;
|
use database::connection;
|
||||||
use dbqueries;
|
use dbqueries;
|
||||||
use utils::{replace_extra_spaces, url_cleaner};
|
use utils::url_cleaner;
|
||||||
|
|
||||||
#[derive(Insertable, AsChangeset)]
|
#[derive(Insertable, AsChangeset)]
|
||||||
#[table_name = "podcast"]
|
#[table_name = "podcast"]
|
||||||
@@ -27,8 +26,10 @@ pub(crate) struct NewPodcast {
|
|||||||
source_id: i32,
|
source_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Insert<(), DataError> for NewPodcast {
|
impl Insert<()> for NewPodcast {
|
||||||
fn insert(&self) -> Result<(), DataError> {
|
type Error = DataError;
|
||||||
|
|
||||||
|
fn insert(&self) -> Result<(), Self::Error> {
|
||||||
use schema::podcast::dsl::*;
|
use schema::podcast::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
let con = db.get()?;
|
let con = db.get()?;
|
||||||
@@ -41,8 +42,10 @@ impl Insert<(), DataError> for NewPodcast {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Update<(), DataError> for NewPodcast {
|
impl Update<()> for NewPodcast {
|
||||||
fn update(&self, podcast_id: i32) -> Result<(), DataError> {
|
type Error = DataError;
|
||||||
|
|
||||||
|
fn update(&self, podcast_id: i32) -> Result<(), Self::Error> {
|
||||||
use schema::podcast::dsl::*;
|
use schema::podcast::dsl::*;
|
||||||
let db = connection();
|
let db = connection();
|
||||||
let con = db.get()?;
|
let con = db.get()?;
|
||||||
@@ -58,7 +61,9 @@ impl Update<(), DataError> for NewPodcast {
|
|||||||
|
|
||||||
// TODO: Maybe return an Enum<Action(Resut)> Instead.
|
// TODO: Maybe return an Enum<Action(Resut)> Instead.
|
||||||
// It would make unti testing better too.
|
// It would make unti testing better too.
|
||||||
impl Index<(), DataError> for NewPodcast {
|
impl Index<()> for NewPodcast {
|
||||||
|
type Error = DataError;
|
||||||
|
|
||||||
fn index(&self) -> Result<(), DataError> {
|
fn index(&self) -> Result<(), DataError> {
|
||||||
let exists = dbqueries::podcast_exists(self.source_id)?;
|
let exists = dbqueries::podcast_exists(self.source_id)?;
|
||||||
|
|
||||||
@@ -78,7 +83,8 @@ impl Index<(), DataError> for NewPodcast {
|
|||||||
|
|
||||||
impl PartialEq<Podcast> for NewPodcast {
|
impl PartialEq<Podcast> for NewPodcast {
|
||||||
fn eq(&self, other: &Podcast) -> bool {
|
fn eq(&self, other: &Podcast) -> bool {
|
||||||
(self.link() == other.link()) && (self.title() == other.title())
|
(self.link() == other.link())
|
||||||
|
&& (self.title() == other.title())
|
||||||
&& (self.image_uri() == other.image_uri())
|
&& (self.image_uri() == other.image_uri())
|
||||||
&& (self.description() == other.description())
|
&& (self.description() == other.description())
|
||||||
&& (self.source_id() == other.source_id())
|
&& (self.source_id() == other.source_id())
|
||||||
@@ -89,23 +95,21 @@ impl NewPodcast {
|
|||||||
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
|
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
|
||||||
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
|
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
|
||||||
let title = chan.title().trim();
|
let title = chan.title().trim();
|
||||||
|
let link = url_cleaner(chan.link().trim());
|
||||||
|
|
||||||
// Prefer itunes summary over rss.description since many feeds put html into
|
let description = ammonia::Builder::new()
|
||||||
// rss.description.
|
// Remove `rel` attributes from `<a>` tags
|
||||||
let summary = chan.itunes_ext().map(|s| s.summary()).and_then(|s| s);
|
.link_rel(None)
|
||||||
let description = if let Some(sum) = summary {
|
.clean(chan.description().trim())
|
||||||
replace_extra_spaces(&ammonia::clean(sum))
|
.to_string();
|
||||||
} else {
|
|
||||||
replace_extra_spaces(&ammonia::clean(chan.description()))
|
|
||||||
};
|
|
||||||
|
|
||||||
let link = url_cleaner(chan.link());
|
// Try to get the itunes img first
|
||||||
let x = chan.itunes_ext().map(|s| s.image());
|
let itunes_img = chan
|
||||||
let image_uri = if let Some(img) = x {
|
.itunes_ext()
|
||||||
img.map(|s| s.to_owned())
|
.and_then(|s| s.image().map(|url| url.trim()))
|
||||||
} else {
|
.map(|s| s.to_owned());
|
||||||
chan.image().map(|foo| foo.url().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()
|
NewPodcastBuilder::default()
|
||||||
.title(title)
|
.title(title)
|
||||||
@@ -162,13 +166,14 @@ mod tests {
|
|||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
|
|
||||||
// Pre-built expected NewPodcast structs.
|
// Pre-built expected NewPodcast structs.
|
||||||
lazy_static!{
|
lazy_static! {
|
||||||
static ref EXPECTED_INTERCEPTED: NewPodcast = {
|
static ref EXPECTED_INTERCEPTED: NewPodcast = {
|
||||||
let descr = "The people behind The Intercept’s fearless reporting and incisive \
|
let descr = "The people behind The Intercept’s fearless reporting and incisive \
|
||||||
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss \
|
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \
|
||||||
the crucial issues of our time: national security, civil liberties, foreign \
|
others—discuss the crucial issues of our time: national security, civil \
|
||||||
policy, and criminal justice. Plus interviews with artists, thinkers, and \
|
liberties, foreign policy, and criminal justice. Plus interviews with \
|
||||||
newsmakers who challenge our preconceptions about the world we live in.";
|
artists, thinkers, and newsmakers who challenge our preconceptions about \
|
||||||
|
the world we live in.";
|
||||||
|
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("Intercepted with Jeremy Scahill")
|
.title("Intercepted with Jeremy Scahill")
|
||||||
@@ -183,11 +188,10 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_LUP: NewPodcast = {
|
static ref EXPECTED_LUP: NewPodcast = {
|
||||||
let descr = "An open show powered by community LINUX Unplugged takes the best attributes \
|
let descr = "An open show powered by community LINUX Unplugged takes the best \
|
||||||
of open collaboration and focuses them into a weekly lifestyle show about \
|
attributes of open collaboration and focuses them into a weekly \
|
||||||
Linux.";
|
lifestyle show about Linux.";
|
||||||
|
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("LINUX Unplugged Podcast")
|
.title("LINUX Unplugged Podcast")
|
||||||
@@ -200,17 +204,16 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_TIPOFF: NewPodcast = {
|
static ref EXPECTED_TIPOFF: NewPodcast = {
|
||||||
let desc = "Welcome to The Tip Off- the podcast where we take you behind the scenes of \
|
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
|
||||||
some of the best investigative journalism from recent years. Each episode \
|
scenes of some of the best investigative journalism from recent years. \
|
||||||
we’ll be digging into an investigative scoop- hearing from the journalists \
|
Each episode we’ll be digging into an investigative scoop- hearing from \
|
||||||
behind the work as they tell us about the leads, the dead-ends and of course, \
|
the journalists behind the work as they tell us about the leads, the \
|
||||||
the tip offs. There’ll be car chases, slammed doors, terrorist cells, \
|
dead-ends and of course, the tip offs. There’ll be car chases, slammed \
|
||||||
meetings in dimly lit bars and cafes, wrangling with despotic regimes and \
|
doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling \
|
||||||
much more. So if you’re curious about the fun, complicated detective work \
|
with despotic regimes and much more. So if you’re curious about the fun, \
|
||||||
that goes into doing great investigative journalism- then this is the podcast \
|
complicated detective work that goes into doing great investigative \
|
||||||
for you.";
|
journalism- then this is the podcast for you.</p>";
|
||||||
|
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("The Tip Off")
|
.title("The Tip Off")
|
||||||
@@ -223,18 +226,16 @@ mod tests {
|
|||||||
.source_id(42)
|
.source_id(42)
|
||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_STARS: NewPodcast = {
|
static ref EXPECTED_STARS: NewPodcast = {
|
||||||
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars is \
|
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars \
|
||||||
a gripping noir science fiction thriller in 14 episodes: Forbidden love, a \
|
is a gripping noir science fiction thriller in 14 episodes: Forbidden \
|
||||||
crashed UFO, an alien body, and an impossible heist unlike any ever \
|
love, a crashed UFO, an alien body, and an impossible heist unlike any \
|
||||||
attempted - scripted by Mac Rogers, the award-winning playwright and writer \
|
ever attempted - scripted by Mac Rogers, the award-winning playwright \
|
||||||
of the multi-million download The Message and LifeAfter.</p>";
|
and writer of the multi-million download The Message and LifeAfter.</p>";
|
||||||
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
|
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
|
||||||
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
|
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
|
||||||
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
|
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
|
||||||
|
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("Steal the Stars")
|
.title("Steal the Stars")
|
||||||
@@ -245,11 +246,10 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref EXPECTED_CODE: NewPodcast = {
|
static ref EXPECTED_CODE: NewPodcast = {
|
||||||
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, David \
|
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \
|
||||||
Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam Livingston-Gray. \
|
David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \
|
||||||
Brought to you by @therubyrep.";
|
Livingston-Gray. Brought to you by @therubyrep.";
|
||||||
|
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("Greater Than Code")
|
.title("Greater Than Code")
|
||||||
@@ -262,7 +262,16 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
|
||||||
|
NewPodcastBuilder::default()
|
||||||
|
.title("Ελληνοφρένεια")
|
||||||
|
.link("https://ellinofreneia.sealabs.net/feed.rss")
|
||||||
|
.description("Ανεπίσημο feed της Ελληνοφρένειας")
|
||||||
|
.image_uri(Some("https://ellinofreneia.sealabs.net/logo.png".into()))
|
||||||
|
.source_id(42)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
|
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
|
||||||
NewPodcastBuilder::default()
|
NewPodcastBuilder::default()
|
||||||
.title("Intercepted with Jeremy Scahill")
|
.title("Intercepted with Jeremy Scahill")
|
||||||
@@ -324,6 +333,15 @@ mod tests {
|
|||||||
assert_eq!(*EXPECTED_CODE, pd);
|
assert_eq!(*EXPECTED_CODE, pd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
let pd = NewPodcast::new(&channel, 42);
|
||||||
|
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
// This maybe could be a doc test on insert.
|
// This maybe could be a doc test on insert.
|
||||||
fn test_new_podcast_insert() {
|
fn test_new_podcast_insert() {
|
||||||
@@ -343,7 +361,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
// TODO: Add more test/checks
|
// TODO: Add more test/checks
|
||||||
// Currently there's a test that only checks new description or title.
|
// 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.
|
// If you have time and want to help, implement the test for the other fields
|
||||||
|
// too.
|
||||||
fn test_new_podcast_update() {
|
fn test_new_podcast_update() {
|
||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
|
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(unused_mut)]
|
|
||||||
|
|
||||||
use diesel;
|
use diesel;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ pub struct Podcast {
|
|||||||
source_id: i32,
|
source_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Save<Podcast, DataError> for Podcast {
|
impl Save<Podcast> for Podcast {
|
||||||
/// Helper method to easily save/"sync" current state of self to the Database.
|
type Error = DataError;
|
||||||
fn save(&self) -> Result<Podcast, 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 db = connection();
|
||||||
let tempdb = db.get()?;
|
let tempdb = db.get()?;
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ use diesel::SaveChangesDsl;
|
|||||||
use rss::Channel;
|
use rss::Channel;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
|
|
||||||
use hyper::client::HttpConnector;
|
use hyper::client::HttpConnector;
|
||||||
use hyper::header::{ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified,
|
use hyper::header::{
|
||||||
Location};
|
ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified, Location, UserAgent,
|
||||||
|
};
|
||||||
|
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
|
||||||
use hyper_tls::HttpsConnector;
|
use hyper_tls::HttpsConnector;
|
||||||
|
|
||||||
// use futures::future::ok;
|
// use futures::future::ok;
|
||||||
|
use futures::future::{loop_fn, Future, Loop};
|
||||||
use futures::prelude::*;
|
use futures::prelude::*;
|
||||||
use futures_cpupool::CpuPool;
|
|
||||||
|
|
||||||
use database::connection;
|
use database::connection;
|
||||||
use errors::DataError;
|
use errors::*;
|
||||||
use feed::{Feed, FeedBuilder};
|
use feed::{Feed, FeedBuilder};
|
||||||
use models::{NewSource, Save};
|
use models::{NewSource, Save};
|
||||||
use schema::source;
|
use schema::source;
|
||||||
|
use USER_AGENT;
|
||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -33,9 +35,12 @@ pub struct Source {
|
|||||||
http_etag: Option<String>,
|
http_etag: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Save<Source, DataError> for Source {
|
impl Save<Source> for Source {
|
||||||
/// Helper method to easily save/"sync" current state of self to the Database.
|
type Error = DataError;
|
||||||
fn save(&self) -> Result<Source, 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 db = connection();
|
||||||
let con = db.get()?;
|
let con = db.get()?;
|
||||||
|
|
||||||
@@ -101,6 +106,10 @@ impl Source {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_err(self, context: &str, code: StatusCode) -> DataError {
|
||||||
|
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
|
||||||
|
}
|
||||||
|
|
||||||
// TODO match on more stuff
|
// TODO match on more stuff
|
||||||
// 301: Moved Permanently
|
// 301: Moved Permanently
|
||||||
// 304: Up to date Feed, checked with the Etag
|
// 304: Up to date Feed, checked with the Etag
|
||||||
@@ -111,73 +120,27 @@ impl Source {
|
|||||||
// 408: Timeout
|
// 408: Timeout
|
||||||
// 410: Feed deleted
|
// 410: Feed deleted
|
||||||
// TODO: Rething this api,
|
// TODO: Rething this api,
|
||||||
fn match_status(mut self, res: Response) -> Result<(Self, Response), DataError> {
|
fn match_status(mut self, res: Response) -> Result<Response, DataError> {
|
||||||
self.update_etag(&res)?;
|
self.update_etag(&res)?;
|
||||||
let code = res.status();
|
let code = res.status();
|
||||||
match code {
|
|
||||||
StatusCode::NotModified => {
|
|
||||||
let err = DataError::HttpStatusError {
|
|
||||||
url: self.uri,
|
|
||||||
status_code: code,
|
|
||||||
context: format!("304: skipping.."),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
match code {
|
||||||
}
|
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
|
||||||
StatusCode::MovedPermanently => {
|
StatusCode::MovedPermanently => {
|
||||||
error!("Feed was moved permanently.");
|
error!("Feed was moved permanently.");
|
||||||
self.handle_301(&res)?;
|
self.handle_301(&res)?;
|
||||||
|
return Err(DataError::F301(self));
|
||||||
let err = DataError::HttpStatusError {
|
|
||||||
url: self.uri,
|
|
||||||
status_code: code,
|
|
||||||
context: format!("301: Feed was moved permanently."),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
|
||||||
}
|
}
|
||||||
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
|
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
|
||||||
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
|
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
|
||||||
StatusCode::Unauthorized => {
|
StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)),
|
||||||
let err = DataError::HttpStatusError {
|
StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)),
|
||||||
url: self.uri,
|
StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)),
|
||||||
status_code: code,
|
StatusCode::RequestTimeout => return Err(self.make_err("408: Request Timeout.", code)),
|
||||||
context: format!("401: Unauthorized."),
|
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
StatusCode::Forbidden => {
|
|
||||||
let err = DataError::HttpStatusError {
|
|
||||||
url: self.uri,
|
|
||||||
status_code: code,
|
|
||||||
context: format!("403: Forbidden."),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
StatusCode::NotFound => return Err(format!("404: Not found.")).map_err(From::from),
|
|
||||||
StatusCode::RequestTimeout => {
|
|
||||||
let err = DataError::HttpStatusError {
|
|
||||||
url: self.uri,
|
|
||||||
status_code: code,
|
|
||||||
context: format!("408: Request Timeout."),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
StatusCode::Gone => {
|
|
||||||
let err = DataError::HttpStatusError {
|
|
||||||
url: self.uri,
|
|
||||||
status_code: code,
|
|
||||||
context: format!("410: Feed was deleted.."),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
_ => info!("HTTP StatusCode: {}", code),
|
_ => info!("HTTP StatusCode: {}", code),
|
||||||
};
|
};
|
||||||
Ok((self, res))
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
|
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
|
||||||
@@ -189,8 +152,6 @@ impl Source {
|
|||||||
self.last_modified = None;
|
self.last_modified = None;
|
||||||
self.save()?;
|
self.save()?;
|
||||||
info!("Feed url was updated succesfully.");
|
info!("Feed url was updated succesfully.");
|
||||||
// TODO: Refresh in place instead of next time, Not a priority.
|
|
||||||
info!("New content will be fetched with the next refesh.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -215,22 +176,34 @@ impl Source {
|
|||||||
// Refactor into TryInto once it lands on stable.
|
// Refactor into TryInto once it lands on stable.
|
||||||
pub fn into_feed(
|
pub fn into_feed(
|
||||||
self,
|
self,
|
||||||
client: &Client<HttpsConnector<HttpConnector>>,
|
client: Client<HttpsConnector<HttpConnector>>,
|
||||||
pool: CpuPool,
|
|
||||||
ignore_etags: bool,
|
ignore_etags: bool,
|
||||||
) -> Box<Future<Item = Feed, Error = DataError>> {
|
) -> impl Future<Item = Feed, Error = DataError> {
|
||||||
let id = self.id();
|
let id = self.id();
|
||||||
let feed = self.request_constructor(client, ignore_etags)
|
let response = loop_fn(self, move |source| {
|
||||||
.and_then(move |(_, res)| response_to_channel(res, pool))
|
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| {
|
.and_then(move |chan| {
|
||||||
FeedBuilder::default()
|
FeedBuilder::default()
|
||||||
.channel(chan)
|
.channel(chan)
|
||||||
.source_id(id)
|
.source_id(id)
|
||||||
.build()
|
.build()
|
||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
});
|
})
|
||||||
|
|
||||||
Box::new(feed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make ignore_etags an Enum for better ergonomics.
|
// TODO: make ignore_etags an Enum for better ergonomics.
|
||||||
@@ -239,49 +212,43 @@ impl Source {
|
|||||||
self,
|
self,
|
||||||
client: &Client<HttpsConnector<HttpConnector>>,
|
client: &Client<HttpsConnector<HttpConnector>>,
|
||||||
ignore_etags: bool,
|
ignore_etags: bool,
|
||||||
) -> Box<Future<Item = (Self, Response), Error = DataError>> {
|
) -> impl Future<Item = Response, Error = DataError> {
|
||||||
// FIXME: remove unwrap somehow
|
// FIXME: remove unwrap somehow
|
||||||
let uri = Uri::from_str(self.uri()).unwrap();
|
let uri = Uri::from_str(self.uri()).unwrap();
|
||||||
let mut req = Request::new(Method::Get, uri);
|
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 !ignore_etags {
|
||||||
if let Some(foo) = self.http_etag() {
|
if let Some(etag) = self.http_etag() {
|
||||||
req.headers_mut().set(IfNoneMatch::Items(vec![
|
let tag = vec![EntityTag::new(true, etag.to_owned())];
|
||||||
EntityTag::new(true, foo.to_owned()),
|
req.headers_mut().set(IfNoneMatch::Items(tag));
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(foo) = self.last_modified() {
|
if let Some(lmod) = self.last_modified() {
|
||||||
if let Ok(x) = foo.parse::<HttpDate>() {
|
if let Ok(date) = lmod.parse::<HttpDate>() {
|
||||||
req.headers_mut().set(IfModifiedSince(x));
|
req.headers_mut().set(IfModifiedSince(date));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let work = client
|
client
|
||||||
.request(req)
|
.request(req)
|
||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
// TODO: tail recursion loop that would follow redirects directly
|
.and_then(move |res| self.match_status(res))
|
||||||
.and_then(move |res| self.match_status(res));
|
|
||||||
Box::new(work)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn response_to_channel(
|
#[allow(needless_pass_by_value)]
|
||||||
res: Response,
|
fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = DataError> + Send {
|
||||||
pool: CpuPool,
|
res.body()
|
||||||
) -> Box<Future<Item = Channel, Error = DataError> + Send> {
|
|
||||||
let chan = res.body()
|
|
||||||
.concat2()
|
.concat2()
|
||||||
.map(|x| x.into_iter())
|
.map(|x| x.into_iter())
|
||||||
.map_err(From::from)
|
.map_err(From::from)
|
||||||
.map(|iter| iter.collect::<Vec<u8>>())
|
.map(|iter| iter.collect::<Vec<u8>>())
|
||||||
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
|
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
|
||||||
.and_then(|buf| {
|
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
|
||||||
Channel::from_str(&buf).or_else(|err| Err(DataError::RssCrateError(format!("{}", err))))
|
|
||||||
});
|
|
||||||
let cpu_chan = pool.spawn(chan);
|
|
||||||
Box::new(cpu_chan)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -296,7 +263,6 @@ mod tests {
|
|||||||
fn test_into_feed() {
|
fn test_into_feed() {
|
||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
|
|
||||||
let pool = CpuPool::new_num_cpus();
|
|
||||||
let mut core = Core::new().unwrap();
|
let mut core = Core::new().unwrap();
|
||||||
let client = Client::configure()
|
let client = Client::configure()
|
||||||
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
|
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
|
||||||
@@ -307,7 +273,7 @@ mod tests {
|
|||||||
let source = Source::from_url(url).unwrap();
|
let source = Source::from_url(url).unwrap();
|
||||||
let id = source.id();
|
let id = source.id();
|
||||||
|
|
||||||
let feed = source.into_feed(&client, pool.clone(), true);
|
let feed = source.into_feed(client, true);
|
||||||
let feed = core.run(feed).unwrap();
|
let feed = core.run(feed).unwrap();
|
||||||
|
|
||||||
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
|
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-149
@@ -2,24 +2,25 @@
|
|||||||
//! Docs.
|
//! Docs.
|
||||||
|
|
||||||
use futures::future::*;
|
use futures::future::*;
|
||||||
use futures_cpupool::CpuPool;
|
use futures::prelude::*;
|
||||||
// use futures::prelude::*;
|
use futures::stream::*;
|
||||||
|
|
||||||
use hyper::Client;
|
|
||||||
use hyper::client::HttpConnector;
|
use hyper::client::HttpConnector;
|
||||||
|
use hyper::Client;
|
||||||
use hyper_tls::HttpsConnector;
|
use hyper_tls::HttpsConnector;
|
||||||
use tokio_core::reactor::Core;
|
use tokio_core::reactor::Core;
|
||||||
|
|
||||||
use num_cpus;
|
use num_cpus;
|
||||||
use rss;
|
use rayon;
|
||||||
|
use rayon_futures::ScopeFutureExt;
|
||||||
|
|
||||||
use Source;
|
|
||||||
use dbqueries;
|
|
||||||
use errors::DataError;
|
use errors::DataError;
|
||||||
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
|
use Source;
|
||||||
|
|
||||||
// use std::sync::{Arc, Mutex};
|
// use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// http://gtk-rs.org/tuto/closures
|
||||||
|
#[macro_export]
|
||||||
macro_rules! clone {
|
macro_rules! clone {
|
||||||
(@param _) => ( _ );
|
(@param _) => ( _ );
|
||||||
(@param $x:ident) => ( $x );
|
(@param $x:ident) => ( $x );
|
||||||
@@ -37,171 +38,81 @@ macro_rules! clone {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
|
||||||
|
|
||||||
/// The pipline to be run for indexing and updating a Podcast feed that originates from
|
/// The pipline to be run for indexing and updating a Podcast feed that originates from
|
||||||
/// `Source.uri`.
|
/// `Source.uri`.
|
||||||
///
|
///
|
||||||
/// Messy temp diagram:
|
/// Messy temp diagram:
|
||||||
/// Source -> GET Request -> Update Etags -> Check Status -> Parse xml/Rss ->
|
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
|
||||||
/// Convert `rss::Channel` into Feed -> Index Podcast -> Index Episodes.
|
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
|
||||||
pub fn pipeline<S: IntoIterator<Item = Source>>(
|
pub fn pipeline<'a, S>(
|
||||||
sources: S,
|
sources: S,
|
||||||
ignore_etags: bool,
|
ignore_etags: bool,
|
||||||
tokio_core: &mut Core,
|
client: &HttpsClient,
|
||||||
pool: &CpuPool,
|
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a
|
||||||
client: Client<HttpsConnector<HttpConnector>>,
|
|
||||||
) -> Result<(), DataError> {
|
|
||||||
let list: Vec<_> = sources
|
|
||||||
.into_iter()
|
|
||||||
.map(clone!(pool => move |s| s.into_feed(&client, pool.clone(), ignore_etags)))
|
|
||||||
.map(|fut| fut.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index()))))
|
|
||||||
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if list.is_empty() {
|
|
||||||
return Err(DataError::EmptyFuturesList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thats not really concurrent yet I think.
|
|
||||||
tokio_core.run(collect_futures(list))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a tokio `reactor::Core`, a `CpuPool`, and a `hyper::Client` and runs the pipeline.
|
|
||||||
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
|
|
||||||
if sources.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = CpuPool::new_num_cpus();
|
|
||||||
let mut core = Core::new()?;
|
|
||||||
let handle = core.handle();
|
|
||||||
let client = Client::configure()
|
|
||||||
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
|
|
||||||
.build(&handle);
|
|
||||||
|
|
||||||
pipeline(sources, ignore_etags, &mut core, &pool, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Docs
|
|
||||||
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
|
|
||||||
let pool = CpuPool::new_num_cpus();
|
|
||||||
let mut core = Core::new()?;
|
|
||||||
let handle = core.handle();
|
|
||||||
|
|
||||||
let client = Client::configure()
|
|
||||||
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
|
|
||||||
.build(&handle);
|
|
||||||
|
|
||||||
let work = s.into_feed(&client, pool.clone(), ignore_etags)
|
|
||||||
.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index())))
|
|
||||||
.map(|_| ());
|
|
||||||
|
|
||||||
core.run(work)
|
|
||||||
}
|
|
||||||
|
|
||||||
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())?;
|
|
||||||
|
|
||||||
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 rowid = old.rowid();
|
|
||||||
|
|
||||||
if ep != old {
|
|
||||||
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
|
|
||||||
} else {
|
|
||||||
Ok(IndexState::NotChanged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn glue_async<'a>(
|
|
||||||
item: &'a rss::Item,
|
|
||||||
id: i32,
|
|
||||||
) -> Box<Future<Item = IndexState<NewEpisode>, Error = DataError> + 'a> {
|
|
||||||
Box::new(
|
|
||||||
result(NewEpisodeMinimal::new(item, id)).and_then(move |ep| determine_ep_state(ep, item)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weird magic from #rust irc channel
|
|
||||||
// kudos to remexre
|
|
||||||
/// FIXME: Docs
|
|
||||||
#[cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
|
||||||
pub fn collect_futures<F>(
|
|
||||||
futures: Vec<F>,
|
|
||||||
) -> Box<Future<Item = Vec<Result<F::Item, F::Error>>, Error = DataError>>
|
|
||||||
where
|
where
|
||||||
F: 'static + Future,
|
S: Stream<Item = Source, Error = DataError> + 'a,
|
||||||
<F as Future>::Item: 'static,
|
|
||||||
<F as Future>::Error: 'static,
|
|
||||||
{
|
{
|
||||||
Box::new(loop_fn((futures, vec![]), |(futures, mut done)| {
|
sources
|
||||||
select_all(futures).then(|r| {
|
.and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags)))
|
||||||
let (r, rest) = match r {
|
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index())))
|
||||||
Ok((r, _, rest)) => (Ok(r), rest),
|
// the stream will stop at the first error so
|
||||||
Err((r, _, rest)) => (Err(r), rest),
|
// we ensure that everything will succeded regardless.
|
||||||
};
|
.map_err(|err| error!("Error: {}", err))
|
||||||
done.push(r);
|
.then(|_| ok::<(), DataError>(()))
|
||||||
if rest.is_empty() {
|
.collect()
|
||||||
Ok(Loop::Break(done))
|
}
|
||||||
} else {
|
|
||||||
Ok(Loop::Continue((rest, done)))
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use Source;
|
|
||||||
use database::truncate_db;
|
use database::truncate_db;
|
||||||
|
use dbqueries;
|
||||||
|
use Source;
|
||||||
|
|
||||||
// (path, url) tuples.
|
// (path, url) tuples.
|
||||||
const URLS: &[(&str, &str)] = {
|
const URLS: &[&str] = &[
|
||||||
&[
|
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
||||||
(
|
com/InterceptedWithJeremyScahill",
|
||||||
"tests/feeds/2018-01-20-Intercepted.xml",
|
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
|
||||||
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
|
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
|
||||||
com/InterceptedWithJeremyScahill",
|
"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",
|
||||||
"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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// Insert feeds and update/index them.
|
/// Insert feeds and update/index them.
|
||||||
fn test_pipeline() {
|
fn test_pipeline() {
|
||||||
truncate_db().unwrap();
|
truncate_db().unwrap();
|
||||||
URLS.iter().for_each(|&(_, url)| {
|
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.
|
// Index the urls into the source table.
|
||||||
Source::from_url(url).unwrap();
|
Source::from_url(url).unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let sources = dbqueries::get_sources().unwrap();
|
let sources = dbqueries::get_sources().unwrap();
|
||||||
run(sources, true).unwrap();
|
run(sources, true).unwrap();
|
||||||
|
|
||||||
@@ -210,7 +121,7 @@ mod tests {
|
|||||||
run(sources, true).unwrap();
|
run(sources, true).unwrap();
|
||||||
|
|
||||||
// Assert the index rows equal the controlled results
|
// Assert the index rows equal the controlled results
|
||||||
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
|
assert_eq!(dbqueries::get_sources().unwrap().len(), 6);
|
||||||
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
|
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
|
||||||
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
|
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-61
@@ -3,7 +3,6 @@
|
|||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
use itertools::Itertools;
|
|
||||||
use url::{Position, Url};
|
use url::{Position, Url};
|
||||||
|
|
||||||
use dbqueries;
|
use dbqueries;
|
||||||
@@ -14,7 +13,8 @@ use xdg_dirs::DL_DIR;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Scan downloaded `episode` entries that might have broken `local_uri`s and set them to `None`.
|
/// Scan downloaded `episode` entries that might have broken `local_uri`s and
|
||||||
|
/// set them to `None`.
|
||||||
fn download_checker() -> Result<(), DataError> {
|
fn download_checker() -> Result<(), DataError> {
|
||||||
let mut episodes = dbqueries::get_downloaded_episodes()?;
|
let mut episodes = dbqueries::get_downloaded_episodes()?;
|
||||||
|
|
||||||
@@ -28,34 +28,31 @@ fn download_checker() -> Result<(), DataError> {
|
|||||||
})
|
})
|
||||||
.for_each(|ep| {
|
.for_each(|ep| {
|
||||||
ep.set_local_uri(None);
|
ep.set_local_uri(None);
|
||||||
if let Err(err) = ep.save() {
|
ep.save()
|
||||||
error!("Error while trying to update episode: {:#?}", ep);
|
.map_err(|err| error!("{}", err))
|
||||||
error!("{}", err);
|
.map_err(|_| error!("Error while trying to update episode: {:#?}", ep))
|
||||||
};
|
.ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete watched `episodes` that have exceded their liftime after played.
|
/// Delete watched `episodes` that have exceded their liftime after played.
|
||||||
fn played_cleaner() -> Result<(), DataError> {
|
fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
|
||||||
let mut episodes = dbqueries::get_played_cleaner_episodes()?;
|
let mut episodes = dbqueries::get_played_cleaner_episodes()?;
|
||||||
|
let now_utc = cleanup_date.timestamp() as i32;
|
||||||
|
|
||||||
let now_utc = Utc::now().timestamp() as i32;
|
|
||||||
episodes
|
episodes
|
||||||
.par_iter_mut()
|
.par_iter_mut()
|
||||||
.filter(|ep| ep.local_uri().is_some() && ep.played().is_some())
|
.filter(|ep| ep.local_uri().is_some() && ep.played().is_some())
|
||||||
.for_each(|ep| {
|
.for_each(|ep| {
|
||||||
// TODO: expose a config and a user set option.
|
let limit = ep.played().unwrap();
|
||||||
// Chnage the test too when exposed
|
|
||||||
let limit = ep.played().unwrap() + 172_800; // add 2days in seconds
|
|
||||||
if now_utc > limit {
|
if now_utc > limit {
|
||||||
if let Err(err) = delete_local_content(ep) {
|
delete_local_content(ep)
|
||||||
error!("Error while trying to delete file: {:?}", ep.local_uri());
|
.map(|_| info!("Episode {:?} was deleted succesfully.", ep.local_uri()))
|
||||||
error!("{}", err);
|
.map_err(|err| error!("Error: {}", err))
|
||||||
} else {
|
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri()))
|
||||||
info!("Episode {:?} was deleted succesfully.", ep.local_uri());
|
.ok();
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -91,10 +88,10 @@ fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
|
|||||||
///
|
///
|
||||||
/// Runs a cleaner for played Episode's that are pass the lifetime limit and
|
/// Runs a cleaner for played Episode's that are pass the lifetime limit and
|
||||||
/// scheduled for removal.
|
/// scheduled for removal.
|
||||||
pub fn checkup() -> Result<(), DataError> {
|
pub fn checkup(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
|
||||||
info!("Running database checks.");
|
info!("Running database checks.");
|
||||||
download_checker()?;
|
download_checker()?;
|
||||||
played_cleaner()?;
|
played_cleaner(cleanup_date)?;
|
||||||
info!("Checks completed.");
|
info!("Checks completed.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -111,22 +108,6 @@ pub fn url_cleaner(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper functions that strips extra spaces and newlines and ignores the tabs.
|
|
||||||
#[allow(match_same_arms)]
|
|
||||||
pub fn replace_extra_spaces(s: &str) -> String {
|
|
||||||
s.trim()
|
|
||||||
.chars()
|
|
||||||
.filter(|ch| *ch != '\t')
|
|
||||||
.coalesce(|current, next| match (current, next) {
|
|
||||||
('\n', '\n') => Ok('\n'),
|
|
||||||
('\n', ' ') => Ok('\n'),
|
|
||||||
(' ', '\n') => Ok('\n'),
|
|
||||||
(' ', ' ') => Ok(' '),
|
|
||||||
(_, _) => Err((current, next)),
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the URI of a Podcast Downloads given it's title.
|
/// Returns the URI of a Podcast Downloads given it's title.
|
||||||
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
|
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 podcast rowid
|
||||||
@@ -181,6 +162,7 @@ mod tests {
|
|||||||
|
|
||||||
use self::tempdir::TempDir;
|
use self::tempdir::TempDir;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
use database::truncate_db;
|
use database::truncate_db;
|
||||||
use models::NewEpisodeBuilder;
|
use models::NewEpisodeBuilder;
|
||||||
@@ -260,15 +242,14 @@ mod tests {
|
|||||||
fn test_played_cleaner_expired() {
|
fn test_played_cleaner_expired() {
|
||||||
let _tmp_dir = helper_db();
|
let _tmp_dir = helper_db();
|
||||||
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
||||||
let now_utc = Utc::now().timestamp() as i32;
|
let cleanup_date = Utc::now() - Duration::seconds(1000);
|
||||||
// let limit = now_utc - 172_800;
|
let epoch = cleanup_date.timestamp() as i32 - 1;
|
||||||
let epoch = now_utc - 200_000;
|
|
||||||
episode.set_played(Some(epoch));
|
episode.set_played(Some(epoch));
|
||||||
episode.save().unwrap();
|
episode.save().unwrap();
|
||||||
let valid_path = episode.local_uri().unwrap().to_owned();
|
let valid_path = episode.local_uri().unwrap().to_owned();
|
||||||
|
|
||||||
// This should delete the file
|
// This should delete the file
|
||||||
played_cleaner().unwrap();
|
played_cleaner(cleanup_date).unwrap();
|
||||||
assert_eq!(Path::new(&valid_path).exists(), false);
|
assert_eq!(Path::new(&valid_path).exists(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,15 +257,14 @@ mod tests {
|
|||||||
fn test_played_cleaner_none() {
|
fn test_played_cleaner_none() {
|
||||||
let _tmp_dir = helper_db();
|
let _tmp_dir = helper_db();
|
||||||
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
|
||||||
let now_utc = Utc::now().timestamp() as i32;
|
let cleanup_date = Utc::now() - Duration::seconds(1000);
|
||||||
// limit = 172_800;
|
let epoch = cleanup_date.timestamp() as i32 + 1;
|
||||||
let epoch = now_utc - 20_000;
|
|
||||||
episode.set_played(Some(epoch));
|
episode.set_played(Some(epoch));
|
||||||
episode.save().unwrap();
|
episode.save().unwrap();
|
||||||
let valid_path = episode.local_uri().unwrap().to_owned();
|
let valid_path = episode.local_uri().unwrap().to_owned();
|
||||||
|
|
||||||
// This should not delete the file
|
// This should not delete the file
|
||||||
played_cleaner().unwrap();
|
played_cleaner(cleanup_date).unwrap();
|
||||||
assert_eq!(Path::new(&valid_path).exists(), true);
|
assert_eq!(Path::new(&valid_path).exists(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,24 +279,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_whitespace() {
|
// This test needs access to local system so we ignore it by default.
|
||||||
let bad_txt = "1 2 3 4 5";
|
#[ignore]
|
||||||
let valid_txt = "1 2 3 4 5";
|
|
||||||
|
|
||||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
|
||||||
|
|
||||||
let bad_txt = "1 2 3 \n 4 5\n";
|
|
||||||
let valid_txt = "1 2 3\n4 5";
|
|
||||||
|
|
||||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
|
||||||
|
|
||||||
let bad_txt = "1 2 3 \n\n\n \n 4 5\n";
|
|
||||||
let valid_txt = "1 2 3\n4 5";
|
|
||||||
|
|
||||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_dl_folder() {
|
fn test_get_dl_folder() {
|
||||||
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
|
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
|
||||||
assert_eq!(get_download_folder("foo").unwrap(), foo_);
|
assert_eq!(get_download_folder("foo").unwrap(), foo_);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Ελληνοφρένεια</title>
|
||||||
|
<link>https://ellinofreneia.sealabs.net/feed.rss</link>
|
||||||
|
<description>Ανεπίσημο feed της Ελληνοφρένειας</description>
|
||||||
|
<atom:link href="https://ellinofreneia.sealabs.net/feed.rss" rel="self"/>
|
||||||
|
<copyright>All rights reversed by http://ellinofreneianet.gr/</copyright>
|
||||||
|
<docs>http://www.rssboard.org/rss-specification</docs>
|
||||||
|
<generator>python-feedgen</generator>
|
||||||
|
<image>
|
||||||
|
<url>https://ellinofreneia.sealabs.net/logo.png</url>
|
||||||
|
<title>Ελληνοφρένεια</title>
|
||||||
|
<link>https://ellinofreneia.sealabs.net/feed.rss</link>
|
||||||
|
</image>
|
||||||
|
<language>el</language>
|
||||||
|
<lastBuildDate>Tue, 27 Mar 2018 13:00:56 +0000</lastBuildDate>
|
||||||
|
<pubDate>Tue, 27 Mar 2018 13:00:56 +0000</pubDate>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:name>Τζένη Μπώτση</itunes:name>
|
||||||
|
<itunes:email>tbotsi@example.com</itunes:email>
|
||||||
|
</itunes:owner>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 27ης Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="36087430" type="audio/mpeg"/>
|
||||||
|
<pubDate>Tue, 27 Mar 2018 11:11:02 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2257</itunes:duration>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 26ης Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="34974405" type="audio/mpeg"/>
|
||||||
|
<pubDate>Mon, 26 Mar 2018 11:16:17 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2188</itunes:duration>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 23ης Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="38491114" type="audio/mpeg"/>
|
||||||
|
<pubDate>Fri, 23 Mar 2018 12:11:08 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2408</itunes:duration>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 22ας Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="35607613" type="audio/mpeg"/>
|
||||||
|
<pubDate>Thu, 22 Mar 2018 12:31:20 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2227</itunes:duration>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 21ης Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="36459832" type="audio/mpeg"/>
|
||||||
|
<pubDate>Wed, 21 Mar 2018 12:10:59 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2281</itunes:duration>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Η ρ_φ Ελληνοφρένεια της 20ης Μαρτίου 2018</title>
|
||||||
|
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
|
||||||
|
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="34507127" type="audio/mpeg"/>
|
||||||
|
<pubDate>Tue, 20 Mar 2018 12:11:06 +0000</pubDate>
|
||||||
|
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
|
||||||
|
<itunes:duration>2159</itunes:duration>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -38,3 +38,8 @@ Web view: https://web.archive.org/web/20180120104741/https://www.greaterthancode
|
|||||||
|
|
||||||
Raw file: https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast
|
Raw file: https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast
|
||||||
|
|
||||||
|
#### Ellinofreneia
|
||||||
|
|
||||||
|
Web view: https://web.archive.org/web/20180328083913/https://ellinofreneia.sealabs.net/audio/podcast.rss
|
||||||
|
|
||||||
|
Raw file: https://web.archive.org/web/20180328083913if_/https://ellinofreneia.sealabs.net/audio/podcast.rss
|
||||||
@@ -6,11 +6,11 @@ workspace = "../"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
error-chain = "0.11.0"
|
error-chain = "0.11.0"
|
||||||
hyper = "0.11.18"
|
hyper = "0.11.27"
|
||||||
log = "0.4.1"
|
log = "0.4.1"
|
||||||
mime_guess = "1.8.3"
|
mime_guess = "1.8.4"
|
||||||
reqwest = "0.8.4"
|
reqwest = "0.8.5"
|
||||||
tempdir = "0.3.6"
|
tempdir = "0.3.7"
|
||||||
glob = "0.2.11"
|
glob = "0.2.11"
|
||||||
failure = "0.1.1"
|
failure = "0.1.1"
|
||||||
failure_derive = "0.1.1"
|
failure_derive = "0.1.1"
|
||||||
@@ -18,3 +18,5 @@ failure_derive = "0.1.1"
|
|||||||
[dependencies.hammond-data]
|
[dependencies.hammond-data]
|
||||||
path = "../hammond-data"
|
path = "../hammond-data"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "0.5.1"
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ use std::io::{BufWriter, Read, Write};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
|
|
||||||
use hammond_data::xdg_dirs::HAMMOND_CACHE;
|
use hammond_data::xdg_dirs::HAMMOND_CACHE;
|
||||||
|
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
|
||||||
|
|
||||||
// use failure::Error;
|
// use failure::Error;
|
||||||
use errors::DownloadError;
|
use errors::DownloadError;
|
||||||
|
|
||||||
// TODO: Replace path that are of type &str with std::path.
|
// TODO: Replace path that are of type &str with std::path.
|
||||||
// TODO: Have a convention/document absolute/relative paths, if they should end with / or not.
|
// TODO: Have a convention/document absolute/relative paths, if they should end
|
||||||
|
// with / or not.
|
||||||
|
|
||||||
pub trait DownloadProgress {
|
pub trait DownloadProgress {
|
||||||
fn set_downloaded(&mut self, downloaded: u64);
|
fn set_downloaded(&mut self, downloaded: u64);
|
||||||
@@ -75,7 +76,8 @@ fn download_into(
|
|||||||
info!("Extension: {}", ext);
|
info!("Extension: {}", ext);
|
||||||
|
|
||||||
// Construct a temp file to save desired content.
|
// Construct a temp file to save desired content.
|
||||||
// It has to be a `new_in` instead of new cause rename can't move cross filesystems.
|
// It has to be a `new_in` instead of new cause rename can't move cross
|
||||||
|
// filesystems.
|
||||||
let tempdir = TempDir::new_in(HAMMOND_CACHE.to_str().unwrap(), "temp_download")?;
|
let tempdir = TempDir::new_in(HAMMOND_CACHE.to_str().unwrap(), "temp_download")?;
|
||||||
let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),);
|
let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),);
|
||||||
|
|
||||||
@@ -113,7 +115,9 @@ fn get_ext(content: Option<ContentType>) -> Option<String> {
|
|||||||
|
|
||||||
// TODO: Write unit-tests.
|
// TODO: Write unit-tests.
|
||||||
// TODO: Refactor... Somehow.
|
// TODO: Refactor... Somehow.
|
||||||
/// Handles the I/O of fetching a remote file and saving into a Buffer and A File.
|
/// Handles the I/O of fetching a remote file and saving into a Buffer and A
|
||||||
|
/// File.
|
||||||
|
#[allow(needless_pass_by_value)]
|
||||||
fn save_io(
|
fn save_io(
|
||||||
file: &str,
|
file: &str,
|
||||||
resp: &mut reqwest::Response,
|
resp: &mut reqwest::Response,
|
||||||
@@ -193,7 +197,8 @@ pub fn get_episode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
|
pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
|
||||||
let url = pd.image_uri()
|
let url = pd
|
||||||
|
.image_uri()
|
||||||
.ok_or_else(|| DownloadError::NoImageLocation)?
|
.ok_or_else(|| DownloadError::NoImageLocation)?
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
@@ -211,7 +216,8 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
|
|||||||
// For some reason there is no .first() method so nth(0) is used
|
// For some reason there is no .first() method so nth(0) is used
|
||||||
let path = foo.nth(0).and_then(|x| x.ok());
|
let path = foo.nth(0).and_then(|x| x.ok());
|
||||||
if let Some(p) = path {
|
if let Some(p) = path {
|
||||||
return Ok(p.to_str()
|
return Ok(p
|
||||||
|
.to_str()
|
||||||
.ok_or_else(|| DownloadError::InvalidCachedImageLocation)?
|
.ok_or_else(|| DownloadError::InvalidCachedImageLocation)?
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
@@ -230,9 +236,9 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use hammond_data::Source;
|
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
use hammond_data::pipeline;
|
use hammond_data::pipeline;
|
||||||
|
use hammond_data::Source;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#![recursion_limit = "1024"]
|
#![recursion_limit = "1024"]
|
||||||
#![deny(unused_extern_crates, unused)]
|
#![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;
|
extern crate failure;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
@@ -7,6 +10,10 @@ extern crate failure_derive;
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate log;
|
extern crate log;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[macro_use]
|
||||||
|
extern crate pretty_assertions;
|
||||||
|
|
||||||
extern crate glob;
|
extern crate glob;
|
||||||
extern crate hammond_data;
|
extern crate hammond_data;
|
||||||
extern crate hyper;
|
extern crate hyper;
|
||||||
|
|||||||
+21
-11
@@ -6,29 +6,39 @@ version = "0.1.0"
|
|||||||
workspace = "../"
|
workspace = "../"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "0.4.0"
|
chrono = "0.4.2"
|
||||||
dissolve = "0.2.2"
|
crossbeam-channel = "0.1.2"
|
||||||
gdk = "0.7.0"
|
gdk = "0.8.0"
|
||||||
gdk-pixbuf = "0.3.0"
|
gdk-pixbuf = "0.4.0"
|
||||||
gio = "0.3.0"
|
glib = "0.5.0"
|
||||||
glib = "0.4.1"
|
|
||||||
humansize = "1.1.0"
|
humansize = "1.1.0"
|
||||||
lazy_static = "1.0.0"
|
lazy_static = "1.0.0"
|
||||||
log = "0.4.1"
|
log = "0.4.1"
|
||||||
loggerv = "0.7.0"
|
loggerv = "0.7.1"
|
||||||
open = "1.2.1"
|
open = "1.2.1"
|
||||||
rayon = "0.9.0"
|
rayon = "1.0.1"
|
||||||
send-cell = "0.1.2"
|
send-cell = "0.1.3"
|
||||||
url = "1.6.0"
|
url = "1.7.0"
|
||||||
failure = "0.1.1"
|
failure = "0.1.1"
|
||||||
failure_derive = "0.1.1"
|
failure_derive = "0.1.1"
|
||||||
|
regex = "1.0.0"
|
||||||
|
reqwest = "0.8.5"
|
||||||
|
serde_json = "1.0.17"
|
||||||
|
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
|
||||||
|
|
||||||
[dependencies.gtk]
|
[dependencies.gtk]
|
||||||
features = ["v3_22"]
|
features = ["v3_22"]
|
||||||
version = "0.3.0"
|
version = "0.4.1"
|
||||||
|
|
||||||
|
[dependencies.gio]
|
||||||
|
features = ["v2_50"]
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
[dependencies.hammond-data]
|
[dependencies.hammond-data]
|
||||||
path = "../hammond-data"
|
path = "../hammond-data"
|
||||||
|
|
||||||
[dependencies.hammond-downloader]
|
[dependencies.hammond-downloader]
|
||||||
path = "../hammond-downloader"
|
path = "../hammond-downloader"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "0.5.1"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?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,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ Tobias Bernard
|
|||||||
<!-- interface-copyright 2017 - 2018 -->
|
<!-- interface-copyright 2017 - 2018 -->
|
||||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||||
<object class="GtkBox" id="episode_container">
|
<object class="GtkBox" id="episode_container">
|
||||||
|
<property name="height_request">64</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
@@ -40,36 +41,22 @@ Tobias Bernard
|
|||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkBox" id="info_container">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="valign">start</property>
|
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkLabel" id="title_label">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">start</property>
|
<property name="label" translatable="yes">Episode Title</property>
|
||||||
<child>
|
<property name="ellipsize">end</property>
|
||||||
<object class="GtkLabel" id="title_label">
|
<property name="width_chars">55</property>
|
||||||
<property name="visible">True</property>
|
<property name="single_line_mode">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="track_visited_links">False</property>
|
||||||
<property name="label" translatable="yes">Episode Title</property>
|
<property name="lines">1</property>
|
||||||
<property name="ellipsize">end</property>
|
<property name="xalign">0</property>
|
||||||
<property name="single_line_mode">True</property>
|
|
||||||
<property name="max_width_chars">64</property>
|
|
||||||
<property name="track_visited_links">False</property>
|
|
||||||
<property name="lines">1</property>
|
|
||||||
<property name="xalign">0</property>
|
|
||||||
<property name="yalign">0</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">0</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
@@ -81,7 +68,6 @@ Tobias Bernard
|
|||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">start</property>
|
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="date_label">
|
<object class="GtkLabel" id="date_label">
|
||||||
@@ -96,7 +82,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -112,7 +98,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -129,7 +115,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">2</property>
|
<property name="position">2</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -145,7 +131,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">3</property>
|
<property name="position">3</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -162,7 +148,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">4</property>
|
<property name="position">4</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -179,7 +165,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">5</property>
|
<property name="position">5</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -196,43 +182,73 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">6</property>
|
<property name="position">6</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="padding">6</property>
|
<property name="padding">6</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkButtonBox" id="button_box">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="spacing">6</property>
|
<property name="layout_style">center</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="cancel_button">
|
<object class="GtkButton" id="desc_button">
|
||||||
<property name="label" translatable="yes">Cancel</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="receives_default">False</property>
|
<property name="receives_default">True</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="halign">center</property>
|
||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkImage">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="icon_name">view-paged-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
<packing>
|
||||||
|
<property name="expand">True</property>
|
||||||
|
<property name="fill">True</property>
|
||||||
|
<property name="position">0</property>
|
||||||
|
<property name="non_homogeneous">True</property>
|
||||||
|
</packing>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="play_button">
|
||||||
|
<property name="can_focus">True</property>
|
||||||
|
<property name="receives_default">True</property>
|
||||||
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="tooltip_text" translatable="yes">Play this episode</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkImage">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="icon_name">media-playback-start-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="pack_type">end</property>
|
<property name="position">2</property>
|
||||||
<property name="position">0</property>
|
<property name="non_homogeneous">True</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
@@ -240,9 +256,8 @@ Tobias Bernard
|
|||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="receives_default">True</property>
|
<property name="receives_default">True</property>
|
||||||
<property name="no_show_all">True</property>
|
|
||||||
<property name="tooltip_text" translatable="yes">Download this episode</property>
|
<property name="tooltip_text" translatable="yes">Download this episode</property>
|
||||||
<property name="halign">end</property>
|
<property name="halign">center</property>
|
||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
<property name="always_show_image">True</property>
|
<property name="always_show_image">True</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -256,24 +271,17 @@ Tobias Bernard
|
|||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="pack_type">end</property>
|
<property name="position">3</property>
|
||||||
<property name="position">2</property>
|
<property name="non_homogeneous">True</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="play_button">
|
<object class="GtkButton" id="cancel_button">
|
||||||
|
<property name="label" translatable="yes">Cancel</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="receives_default">True</property>
|
<property name="receives_default">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="halign">center</property>
|
||||||
<property name="tooltip_text" translatable="yes">Play this episode</property>
|
|
||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
<child>
|
|
||||||
<object class="GtkImage">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="icon_name">media-playback-start-symbolic</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
@@ -285,8 +293,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="padding">6</property>
|
|
||||||
<property name="pack_type">end</property>
|
<property name="pack_type">end</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
@@ -295,7 +302,6 @@ Tobias Bernard
|
|||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">True</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="padding">6</property>
|
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -305,13 +311,13 @@ Tobias Bernard
|
|||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
<property name="margin_left">6</property>
|
<property name="margin_left">6</property>
|
||||||
<property name="margin_right">6</property>
|
<property name="margin_right">6</property>
|
||||||
<property name="margin_bottom">6</property>
|
<property name="hexpand">True</property>
|
||||||
<property name="pulse_step">0</property>
|
<property name="pulse_step">0</property>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="pack_type">end</property>
|
<property name="padding">6</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ Tobias Bernard
|
|||||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||||
<object class="GtkBox" id="container">
|
<object class="GtkBox" id="container">
|
||||||
<property name="name">container</property>
|
<property name="name">container</property>
|
||||||
|
<property name="width_request">400</property>
|
||||||
|
<property name="height_request">600</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
@@ -40,7 +42,7 @@ Tobias Bernard
|
|||||||
<property name="name">scrolled_window</property>
|
<property name="name">scrolled_window</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="hscrollbar_policy">never</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkViewport">
|
<object class="GtkViewport">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
@@ -49,37 +51,38 @@ Tobias Bernard
|
|||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">center</property>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<child>
|
<child>
|
||||||
<placeholder/>
|
<placeholder/>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="frame_parent">
|
<object class="GtkBox" id="frame_parent">
|
||||||
<property name="width_request">720</property>
|
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">center</property>
|
<property name="margin_left">32</property>
|
||||||
<property name="margin_top">24</property>
|
<property name="margin_right">32</property>
|
||||||
<property name="margin_bottom">24</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="orientation">vertical</property>
|
||||||
<property name="spacing">24</property>
|
<property name="spacing">24</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="today_box">
|
<object class="GtkBox" id="today_box">
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -87,6 +90,7 @@ Tobias Bernard
|
|||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">start</property>
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label" translatable="yes">Today</property>
|
<property name="label" translatable="yes">Today</property>
|
||||||
<attributes>
|
<attributes>
|
||||||
<attribute name="weight" value="bold"/>
|
<attribute name="weight" value="bold"/>
|
||||||
@@ -95,7 +99,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -103,12 +107,14 @@ Tobias Bernard
|
|||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="today_list">
|
<object class="GtkListBox" id="today_list">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="selection_mode">none</property>
|
<property name="selection_mode">none</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@@ -118,22 +124,22 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="yday_box">
|
<object class="GtkBox" id="yday_box">
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -149,7 +155,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -157,12 +163,14 @@ Tobias Bernard
|
|||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="yday_list">
|
<object class="GtkListBox" id="yday_list">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="selection_mode">none</property>
|
<property name="selection_mode">none</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@@ -172,22 +180,22 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="week_box">
|
<object class="GtkBox" id="week_box">
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -203,7 +211,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -211,12 +219,14 @@ Tobias Bernard
|
|||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="week_list">
|
<object class="GtkListBox" id="week_list">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="selection_mode">none</property>
|
<property name="selection_mode">none</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@@ -226,22 +236,22 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">2</property>
|
<property name="position">2</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="month_box">
|
<object class="GtkBox" id="month_box">
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -257,7 +267,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -265,12 +275,14 @@ Tobias Bernard
|
|||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="month_list">
|
<object class="GtkListBox" id="month_list">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="selection_mode">none</property>
|
<property name="selection_mode">none</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@@ -280,22 +292,22 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">3</property>
|
<property name="position">3</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="rest_box">
|
<object class="GtkBox" id="rest_box">
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -303,6 +315,7 @@ Tobias Bernard
|
|||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">start</property>
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label" translatable="yes">Older</property>
|
<property name="label" translatable="yes">Older</property>
|
||||||
<attributes>
|
<attributes>
|
||||||
<attribute name="weight" value="bold"/>
|
<attribute name="weight" value="bold"/>
|
||||||
@@ -311,7 +324,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -319,6 +332,7 @@ Tobias Bernard
|
|||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
@@ -326,6 +340,7 @@ Tobias Bernard
|
|||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="selection_mode">none</property>
|
<property name="selection_mode">none</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@@ -335,14 +350,14 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">1</property>
|
<property name="position">1</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">5</property>
|
<property name="position">5</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -357,13 +372,14 @@ Tobias Bernard
|
|||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<child>
|
<child>
|
||||||
<placeholder/>
|
<placeholder/>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">2</property>
|
<property name="position">2</property>
|
||||||
</packing>
|
</packing>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -33,14 +33,15 @@ Tobias Bernard
|
|||||||
<object class="GtkBox" id="container">
|
<object class="GtkBox" id="container">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="margin_top">6</property>
|
||||||
|
<property name="margin_bottom">6</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkImage" id="cover">
|
<object class="GtkImage" id="cover">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="margin_left">6</property>
|
<property name="margin_left">6</property>
|
||||||
<property name="margin_top">6</property>
|
|
||||||
<property name="margin_bottom">6</property>
|
|
||||||
<property name="pixel_size">64</property>
|
<property name="pixel_size">64</property>
|
||||||
<property name="icon_name">image-x-generic-symbolic</property>
|
<property name="icon_name">image-x-generic-symbolic</property>
|
||||||
</object>
|
</object>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="back_button">
|
<object class="GtkButton" id="back">
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="receives_default">False</property>
|
<property name="receives_default">False</property>
|
||||||
<property name="no_show_all">True</property>
|
<property name="no_show_all">True</property>
|
||||||
@@ -257,12 +257,12 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkMenuButton" id="menu_toggle">
|
<object class="GtkMenuButton" id="menu_button">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="receives_default">False</property>
|
<property name="receives_default">False</property>
|
||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
<property name="popover">menu_popover</property>
|
<property name="use_popover">True</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkImage">
|
<object class="GtkImage">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
@@ -281,98 +281,4 @@ Tobias Bernard
|
|||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<object class="GtkPopover" id="menu_popover">
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="relative_to">menu_toggle</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="margin_left">10</property>
|
|
||||||
<property name="margin_right">10</property>
|
|
||||||
<property name="margin_top">10</property>
|
|
||||||
<property name="margin_bottom">10</property>
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkModelButton">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="sensitive">False</property>
|
|
||||||
<property name="can_focus">True</property>
|
|
||||||
<property name="receives_default">False</property>
|
|
||||||
<property name="text" translatable="yes">Preferences</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">0</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkModelButton" id="update_button">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">True</property>
|
|
||||||
<property name="receives_default">True</property>
|
|
||||||
<property name="text" translatable="yes">Check for new episodes</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">1</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkSeparator">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">2</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkModelButton" id="about_button">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">True</property>
|
|
||||||
<property name="receives_default">False</property>
|
|
||||||
<property name="text" translatable="yes">About</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">6</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkModelButton">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="sensitive">False</property>
|
|
||||||
<property name="can_focus">True</property>
|
|
||||||
<property name="receives_default">False</property>
|
|
||||||
<property name="text" translatable="yes">Help</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">7</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkModelButton">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="sensitive">False</property>
|
|
||||||
<property name="can_focus">True</property>
|
|
||||||
<property name="receives_default">False</property>
|
|
||||||
<property name="text" translatable="yes">Keyboard Shortcuts</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">8</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</interface>
|
</interface>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<interface>
|
||||||
|
<object class="GtkShortcutsWindow" id="help_overlay">
|
||||||
|
<property name="modal">True</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="section-name">shortcuts</property>
|
||||||
|
<property name="max-height">12</property>
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="title" translatable="yes">General</property>
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="accelerator"><primary>r</property>
|
||||||
|
<property name="title" translatable="yes" context="shortcut window">Check for new episodes</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="accelerator"><primary>q</property>
|
||||||
|
<property name="title" translatable="yes" context="shortcut window">Quit the application</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated with glade 3.21.0 -->
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk+" version="3.20"/>
|
||||||
|
<object class="GtkRevealer" id="revealer">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">start</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkFrame">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="label_xalign">0</property>
|
||||||
|
<property name="shadow_type">none</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="margin_left">3</property>
|
||||||
|
<property name="margin_right">3</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="text">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="margin_left">12</property>
|
||||||
|
<property name="margin_right">12</property>
|
||||||
|
<property name="label" translatable="yes">An in-app action notification</property>
|
||||||
|
</object>
|
||||||
|
<packing>
|
||||||
|
<property name="expand">False</property>
|
||||||
|
<property name="fill">False</property>
|
||||||
|
<property name="position">0</property>
|
||||||
|
</packing>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="close">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">True</property>
|
||||||
|
<property name="focus_on_click">False</property>
|
||||||
|
<property name="receives_default">True</property>
|
||||||
|
<property name="halign">center</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="relief">none</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkImage">
|
||||||
|
<property name="visible">True</property>
|
||||||
|
<property name="can_focus">False</property>
|
||||||
|
<property name="icon_name">window-close-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<style>
|
||||||
|
<class name="flat image-button"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
<packing>
|
||||||
|
<property name="expand">False</property>
|
||||||
|
<property name="fill">False</property>
|
||||||
|
<property name="pack_type">end</property>
|
||||||
|
<property name="position">1</property>
|
||||||
|
</packing>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="undo">
|
||||||
|
<property name="label" translatable="yes">Undo</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="text-button"/>
|
||||||
|
</style>
|
||||||
|
</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>
|
||||||
|
<child type="label_item">
|
||||||
|
<placeholder/>
|
||||||
|
</child>
|
||||||
|
<style>
|
||||||
|
<class name="app-notification"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<style>
|
||||||
|
<class name="top"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<interface>
|
||||||
|
<!-- interface-requires gtk+ 3.0 -->
|
||||||
|
<menu id="menu">
|
||||||
|
<section>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Check for new episodes</attribute>
|
||||||
|
<attribute name="action">app.refresh</attribute>
|
||||||
|
<attribute name="accel"><primary>r</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Import Shows</attribute>
|
||||||
|
<attribute name="action">app.import</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Export Shows</attribute>
|
||||||
|
<attribute name="action">app.export</attribute>
|
||||||
|
</item>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Preferences</attribute>
|
||||||
|
<attribute name="action">app.preferences</attribute>
|
||||||
|
</item>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
|
||||||
|
<attribute name="action">win.show-help-overlay</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Help</attribute>
|
||||||
|
<attribute name="action">app.help</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_About</attribute>
|
||||||
|
<attribute name="action">app.about</attribute>
|
||||||
|
</item>
|
||||||
|
</section>
|
||||||
|
</menu>
|
||||||
|
</interface>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -24,13 +24,15 @@ Tobias Bernard
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
<interface domain="">
|
<interface domain="">
|
||||||
<requires lib="gtk+" version="3.12"/>
|
<requires lib="gtk+" version="3.20"/>
|
||||||
<!-- interface-license-type gplv3 -->
|
<!-- interface-license-type gplv3 -->
|
||||||
<!-- interface-name Hammond -->
|
<!-- interface-name Hammond -->
|
||||||
<!-- interface-description A podcast client for the GNOME Desktop -->
|
<!-- interface-description A podcast client for the GNOME Desktop -->
|
||||||
<!-- interface-copyright 2017 - 2018 -->
|
<!-- interface-copyright 2017 - 2018 -->
|
||||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||||
<object class="GtkBox" id="container">
|
<object class="GtkBox" id="container">
|
||||||
|
<property name="width_request">400</property>
|
||||||
|
<property name="height_request">600</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
@@ -39,7 +41,8 @@ Tobias Bernard
|
|||||||
<property name="name">scrolled_window</property>
|
<property name="name">scrolled_window</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="shadow_type">in</property>
|
<property name="vexpand">True</property>
|
||||||
|
<property name="hscrollbar_policy">never</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkViewport">
|
<object class="GtkViewport">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
@@ -48,172 +51,175 @@ Tobias Bernard
|
|||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">center</property>
|
<property name="can_default">True</property>
|
||||||
<property name="margin_top">24</property>
|
<property name="hexpand">True</property>
|
||||||
<property name="margin_bottom">24</property>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<child>
|
<child>
|
||||||
<placeholder/>
|
<placeholder/>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="width_request">624</property>
|
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="halign">center</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="orientation">vertical</property>
|
||||||
<property name="spacing">24</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>
|
<child>
|
||||||
<object class="GtkFrame">
|
<object class="GtkFrame">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="label_xalign">0</property>
|
<property name="label_xalign">0</property>
|
||||||
<property name="shadow_type">none</property>
|
<property name="shadow_type">in</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkListBox" id="episodes">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="selection_mode">none</property>
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkImage" id="cover">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="pixel_size">128</property>
|
|
||||||
<property name="icon_name">image-x-generic-symbolic</property>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</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="orientation">vertical</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<child type="center">
|
|
||||||
<object class="GtkLabel" id="description">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="halign">start</property>
|
|
||||||
<property name="valign">end</property>
|
|
||||||
<property name="label" translatable="yes">Show description</property>
|
|
||||||
<property name="wrap">True</property>
|
|
||||||
<property name="max_width_chars">57</property>
|
|
||||||
<attributes>
|
|
||||||
<attribute name="weight" value="medium"/>
|
|
||||||
</attributes>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">False</property>
|
|
||||||
<property name="pack_type">end</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">5</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="pack_type">end</property>
|
|
||||||
<property name="position">0</property>
|
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">True</property>
|
|
||||||
<property name="fill">True</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>
|
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child type="label_item">
|
<child type="label_item">
|
||||||
@@ -222,27 +228,8 @@ Tobias Bernard
|
|||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">False</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">False</property>
|
||||||
<property name="position">0</property>
|
<property name="position">2</property>
|
||||||
</packing>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkFrame" id="episodes">
|
|
||||||
<property name="visible">True</property>
|
|
||||||
<property name="can_focus">False</property>
|
|
||||||
<property name="label_xalign">0</property>
|
|
||||||
<property name="shadow_type">in</property>
|
|
||||||
<child>
|
|
||||||
<placeholder/>
|
|
||||||
</child>
|
|
||||||
<child type="label_item">
|
|
||||||
<placeholder/>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
<packing>
|
|
||||||
<property name="expand">False</property>
|
|
||||||
<property name="fill">True</property>
|
|
||||||
<property name="position">1</property>
|
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
@@ -256,14 +243,16 @@ Tobias Bernard
|
|||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
|
<property name="hexpand">True</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<child>
|
<child>
|
||||||
<placeholder/>
|
<placeholder/>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">False</property>
|
<property name="fill">False</property>
|
||||||
|
<property name="pack_type">end</property>
|
||||||
<property name="position">2</property>
|
<property name="position">2</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
@@ -273,10 +262,39 @@ Tobias Bernard
|
|||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">True</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</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>
|
</interface>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generated with glade 3.21.0
|
<!-- Generated with glade 3.22.0
|
||||||
|
|
||||||
Copyright (C) 2017 - 2018
|
Copyright (C) 2017 - 2018
|
||||||
|
|
||||||
@@ -39,26 +39,17 @@ Tobias Bernard
|
|||||||
<property name="valign">center</property>
|
<property name="valign">center</property>
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkOverlay">
|
<object class="GtkImage" id="pd_cover">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">False</property>
|
<property name="can_focus">False</property>
|
||||||
<child>
|
<property name="halign">center</property>
|
||||||
<placeholder/>
|
<property name="valign">center</property>
|
||||||
</child>
|
<property name="pixel_size">256</property>
|
||||||
<child type="overlay">
|
<property name="icon_name">image-x-generic-symbolic</property>
|
||||||
<object class="GtkImage" id="pd_cover">
|
<property name="icon_size">0</property>
|
||||||
<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>
|
|
||||||
</child>
|
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
<property name="expand">True</property>
|
<property name="expand">False</property>
|
||||||
<property name="fill">True</property>
|
<property name="fill">True</property>
|
||||||
<property name="position">0</property>
|
<property name="position">0</property>
|
||||||
</packing>
|
</packing>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ Tobias Bernard
|
|||||||
<property name="name">scrolled_window</property>
|
<property name="name">scrolled_window</property>
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
<property name="can_focus">True</property>
|
<property name="can_focus">True</property>
|
||||||
<property name="shadow_type">in</property>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkViewport">
|
<object class="GtkViewport">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
|
|
||||||
install_data('org.gnome.Hammond.desktop', install_dir : datadir + '/applications')
|
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.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')
|
||||||
@@ -5,19 +5,32 @@
|
|||||||
<project_license>GPL-3.0</project_license>
|
<project_license>GPL-3.0</project_license>
|
||||||
<metadata_license>CC0-1.0</metadata_license>
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
<developer_name>Jordan Petridis</developer_name>
|
<developer_name>Jordan Petridis</developer_name>
|
||||||
<summary>GNOME Podcast Client written in Rust</summary>
|
<summary>Modern Podcast Client for the GNOME desktop</summary>
|
||||||
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
|
<description xml:lang="en">
|
||||||
<description>
|
<p>
|
||||||
Hammond is a Gtk+ Podcast client for the GNOME Desktop written in Rust
|
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
|
||||||
|
desktop written in Rust.
|
||||||
|
</p>
|
||||||
</description>
|
</description>
|
||||||
|
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcasts_view.png</image>
|
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image>
|
||||||
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcast_widget.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>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.3.0" date="2018-02-11"/>
|
<release version="0.3.4" date="2018-05-20"/>
|
||||||
</releases>
|
</releases>
|
||||||
<update_contact>jordanpetridis@protonmail.com</update_contact>
|
<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>
|
</component>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<schemalist>
|
||||||
|
<enum id="org.gnome.Hammond.timePeriods">
|
||||||
|
<value nick="seconds" value="1"/>
|
||||||
|
<value nick="minutes" value="2"/>
|
||||||
|
<value nick="hours" value="3"/>
|
||||||
|
<value nick="days" value="4"/>
|
||||||
|
<value nick="weeks" value="5"/>
|
||||||
|
<value nick="months" value="6"/>
|
||||||
|
</enum>
|
||||||
|
|
||||||
|
<schema path="/org/gnome/hammond/" id="org.gnome.Hammond">
|
||||||
|
<key name="persist-window-geometry-top" type="i">
|
||||||
|
<default>-1</default>
|
||||||
|
<summary>Top position of the last open main window</summary>
|
||||||
|
</key>
|
||||||
|
<key name="persist-window-geometry-left" type="i">
|
||||||
|
<default>-1</default>
|
||||||
|
<summary>Left position of the last open main window</summary>
|
||||||
|
</key>
|
||||||
|
<key name="persist-window-geometry-height" type="i">
|
||||||
|
<default>640</default>
|
||||||
|
<summary>Height of the last open main window</summary>
|
||||||
|
</key>
|
||||||
|
<key name="persist-window-geometry-width" type="i">
|
||||||
|
<default>860</default>
|
||||||
|
<summary>Width of the last open main window</summary>
|
||||||
|
</key>
|
||||||
|
<key name="persist-window-geometry-maximized" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Maximized state of the last open main window</summary>
|
||||||
|
</key>
|
||||||
|
|
||||||
|
<key name="dark-theme" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Enable or disable dark theme</summary>
|
||||||
|
</key>
|
||||||
|
|
||||||
|
<key name="refresh-interval" type="b">
|
||||||
|
<default>true</default>
|
||||||
|
<summary>Whether to periodically refresh content</summary>
|
||||||
|
</key>
|
||||||
|
<key name="refresh-interval-time" type="i">
|
||||||
|
<range min="1" max="100"/>
|
||||||
|
<default>1</default>
|
||||||
|
<summary>How many periods of time to wait between automatic refreshes</summary>
|
||||||
|
</key>
|
||||||
|
<key name="refresh-interval-period" enum="org.gnome.Hammond.timePeriods">
|
||||||
|
<default>'hours'</default>
|
||||||
|
<summary>What period of time to wait between automatic refreshes</summary>
|
||||||
|
</key>
|
||||||
|
<key name="refresh-on-startup" type="b">
|
||||||
|
<default>true</default>
|
||||||
|
<summary>Whether to refresh content after startup</summary>
|
||||||
|
</key>
|
||||||
|
|
||||||
|
<key name="cleanup-age-time" type="i">
|
||||||
|
<range min="1" max="100"/>
|
||||||
|
<default>2</default>
|
||||||
|
<summary>How many periods of time to wait between automatic cleanups</summary>
|
||||||
|
</key>
|
||||||
|
<key name="cleanup-age-period" enum="org.gnome.Hammond.timePeriods">
|
||||||
|
<default>'days'</default>
|
||||||
|
<summary>What period of time to wait between automatic cleanups</summary>
|
||||||
|
</key>
|
||||||
|
</schema>
|
||||||
|
</schemalist>
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/org/gnome/hammond/">
|
<gresource prefix="/org/gnome/Hammond/">
|
||||||
<file preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/show_widget.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/empty_view.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_show.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/shows_view.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/shows_child.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_view.ui</file>
|
||||||
<file preprocess="xml-stripblanks">gtk/headerbar.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" preprocess="xml-stripblanks">gtk/menus.ui</file>
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
|
||||||
<file compressed="true">gtk/style.css</file>
|
<file compressed="true">gtk/style.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
|
|||||||
+238
-107
@@ -1,28 +1,47 @@
|
|||||||
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
|
#![allow(new_without_default)]
|
||||||
|
|
||||||
|
use gio::{
|
||||||
|
ActionMapExt, ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt,
|
||||||
|
SimpleAction, SimpleActionExt,
|
||||||
|
};
|
||||||
use glib;
|
use glib;
|
||||||
use gtk;
|
use gtk;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
use gtk::SettingsExt as GtkSettingsExt;
|
||||||
|
|
||||||
use hammond_data::{Podcast, Source};
|
use crossbeam_channel::{unbounded, Sender};
|
||||||
use hammond_data::utils::checkup;
|
use hammond_data::{Podcast, dbqueries};
|
||||||
|
|
||||||
use headerbar::Header;
|
use headerbar::Header;
|
||||||
use stacks::Content;
|
use settings::{self, WindowGeometry};
|
||||||
|
use stacks::{Content, PopulatedState};
|
||||||
use utils;
|
use utils;
|
||||||
|
use widgets::appnotif::{InAppNotification, UndoState};
|
||||||
|
use widgets::{about_dialog, mark_all_notif, remove_show_notif};
|
||||||
|
|
||||||
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
/// Creates an action named $called in the action map $on with the handler $handle
|
||||||
|
macro_rules! action {
|
||||||
|
($on:expr, $called:expr, $handle:expr) => {{
|
||||||
|
// Create a stateless, parameterless action
|
||||||
|
let act = SimpleAction::new($called, None);
|
||||||
|
// Connect the handler
|
||||||
|
act.connect_activate($handle);
|
||||||
|
// Add it to the map
|
||||||
|
$on.add_action(&act);
|
||||||
|
// Return the action
|
||||||
|
act
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
UpdateSources(Option<Source>),
|
|
||||||
RefreshAllViews,
|
RefreshAllViews,
|
||||||
RefreshEpisodesView,
|
RefreshEpisodesView,
|
||||||
RefreshEpisodesViewBGR,
|
RefreshEpisodesViewBGR,
|
||||||
RefreshShowsView,
|
RefreshShowsView,
|
||||||
RefreshWidget,
|
|
||||||
RefreshWidgetIfVis,
|
|
||||||
ReplaceWidget(Arc<Podcast>),
|
ReplaceWidget(Arc<Podcast>),
|
||||||
RefreshWidgetIfSame(i32),
|
RefreshWidgetIfSame(i32),
|
||||||
ShowWidgetAnimated,
|
ShowWidgetAnimated,
|
||||||
@@ -31,20 +50,21 @@ pub enum Action {
|
|||||||
HeaderBarNormal,
|
HeaderBarNormal,
|
||||||
HeaderBarShowUpdateIndicator,
|
HeaderBarShowUpdateIndicator,
|
||||||
HeaderBarHideUpdateIndicator,
|
HeaderBarHideUpdateIndicator,
|
||||||
|
MarkAllPlayerNotification(Arc<Podcast>),
|
||||||
|
RemoveShow(Arc<Podcast>),
|
||||||
|
ErrorNotification(String),
|
||||||
|
AboutWidget(i32),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct App {
|
pub struct App {
|
||||||
app_instance: gtk::Application,
|
app_instance: gtk::Application,
|
||||||
window: gtk::Window,
|
settings: Settings,
|
||||||
header: Arc<Header>,
|
|
||||||
content: Arc<Content>,
|
|
||||||
receiver: Receiver<Action>,
|
|
||||||
sender: Sender<Action>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> App {
|
pub fn new() -> App {
|
||||||
|
let settings = Settings::new("org.gnome.Hammond");
|
||||||
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
|
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
|
||||||
.expect("Application Initialization failed...");
|
.expect("Application Initialization failed...");
|
||||||
|
|
||||||
@@ -52,117 +72,228 @@ impl App {
|
|||||||
glib::set_application_name("Hammond");
|
glib::set_application_name("Hammond");
|
||||||
glib::set_prgname(Some("Hammond"));
|
glib::set_prgname(Some("Hammond"));
|
||||||
|
|
||||||
// Create the main window
|
let cleanup_date = settings::get_cleanup_date(&settings);
|
||||||
let window = gtk::Window::new(gtk::WindowType::Toplevel);
|
utils::cleanup(cleanup_date);
|
||||||
window.set_default_size(860, 640);
|
|
||||||
window.set_title("Hammond");
|
|
||||||
let app_clone = application.clone();
|
|
||||||
window.connect_delete_event(move |_, _| {
|
|
||||||
app_clone.quit();
|
|
||||||
Inhibit(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
let (sender, receiver) = channel();
|
application.connect_startup(clone!(settings => move |app| {
|
||||||
|
let (sender, receiver) = unbounded();
|
||||||
|
|
||||||
// Create a content instance
|
App::setup_gactions(&app, &sender);
|
||||||
let content =
|
|
||||||
Arc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
|
|
||||||
|
|
||||||
// Create the headerbar
|
app.connect_activate(clone!(sender, settings => move |app| {
|
||||||
let header = Arc::new(Header::new(&content, &window, sender.clone()));
|
// Get the current window (if any)
|
||||||
|
if let Some(window) = app.get_active_window() {
|
||||||
|
// Already open, just raise the window
|
||||||
|
window.present();
|
||||||
|
} else {
|
||||||
|
// Time to open one!
|
||||||
|
// Create the main window
|
||||||
|
let window = gtk::ApplicationWindow::new(&app);
|
||||||
|
window.set_title("Hammond");
|
||||||
|
|
||||||
// Add the content main stack to the window.
|
window.connect_delete_event(clone!(app, settings => move |window, _| {
|
||||||
window.add(&content.get_stack());
|
WindowGeometry::from_window(&window).write(&settings);
|
||||||
|
app.quit();
|
||||||
|
Inhibit(false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
action!(window, "menu", clone!(header => move |_, _| {
|
||||||
|
header.open_menu();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
WindowGeometry::from_settings(&settings).apply(&window);
|
||||||
|
|
||||||
|
App::setup_timed_callbacks(&sender, &settings);
|
||||||
|
|
||||||
|
window.show_all();
|
||||||
|
window.activate();
|
||||||
|
|
||||||
|
gtk::timeout_add(50, clone!(sender, receiver => move || {
|
||||||
|
// Uses receiver, content, header, sender, overlay
|
||||||
|
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)) =>
|
||||||
|
header.switch_to_back(&title),
|
||||||
|
Ok(Action::HeaderBarNormal) => header.switch_to_normal(),
|
||||||
|
Ok(Action::HeaderBarShowUpdateIndicator) =>
|
||||||
|
header.show_update_notification(),
|
||||||
|
Ok(Action::HeaderBarHideUpdateIndicator) =>
|
||||||
|
header.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);
|
||||||
|
}
|
||||||
|
Ok(Action::AboutWidget(rowid)) => {
|
||||||
|
if let Ok(d) = dbqueries::get_episode_description(rowid) {
|
||||||
|
if let Some(_desc) = d {
|
||||||
|
let _stack = content.get_visible_stack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
App {
|
App {
|
||||||
app_instance: application,
|
app_instance: application,
|
||||||
window,
|
settings,
|
||||||
header,
|
|
||||||
content,
|
|
||||||
receiver,
|
|
||||||
sender,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setup_timed_callbacks(&self) {
|
fn setup_timed_callbacks(sender: &Sender<Action>, settings: &Settings) {
|
||||||
let sender = self.sender.clone();
|
App::setup_dark_theme(settings);
|
||||||
|
App::setup_refresh_on_startup(&sender, settings);
|
||||||
|
App::setup_auto_refresh(&sender, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_dark_theme(settings: &Settings) {
|
||||||
|
let gtk_settings = gtk::Settings::get_default().unwrap();
|
||||||
|
let enabled = settings.get_boolean("dark-theme");
|
||||||
|
|
||||||
|
gtk_settings.set_property_gtk_application_prefer_dark_theme(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_refresh_on_startup(sender: &Sender<Action>, settings: &Settings) {
|
||||||
// Update the feeds right after the Application is initialized.
|
// Update the feeds right after the Application is initialized.
|
||||||
gtk::timeout_add_seconds(2, move || {
|
let sender = sender.clone();
|
||||||
utils::refresh_feed_wrapper(None, sender.clone());
|
if settings.get_boolean("refresh-on-startup") {
|
||||||
glib::Continue(false)
|
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(sender: &Sender<Action>, settings: &Settings) {
|
||||||
|
let refresh_interval = settings::get_refresh_interval(&settings).num_seconds() as u32;
|
||||||
|
|
||||||
|
info!("Auto-refresh every {:?} seconds.", refresh_interval);
|
||||||
|
|
||||||
|
let sender = sender.clone();
|
||||||
|
gtk::timeout_add_seconds(refresh_interval, move || {
|
||||||
|
let s: Option<Vec<_>> = None;
|
||||||
|
utils::refresh(s, sender.clone());
|
||||||
|
|
||||||
let sender = self.sender.clone();
|
|
||||||
// Auto-updater, runs every hour.
|
|
||||||
// TODO: expose the interval in which it run to a user setting.
|
|
||||||
gtk::timeout_add_seconds(3600, move || {
|
|
||||||
utils::refresh_feed_wrapper(None, sender.clone());
|
|
||||||
glib::Continue(true)
|
glib::Continue(true)
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Run a database checkup once the application is initialized.
|
/// Define the `GAction`s.
|
||||||
gtk::timeout_add(300, || {
|
///
|
||||||
if let Err(err) = checkup() {
|
/// Used in menus and the keyboard shortcuts dialog.
|
||||||
error!("Check up failed: {}", err);
|
fn setup_gactions(app: >k::Application, sender: &Sender<Action>) {
|
||||||
}
|
// Create the `refresh` action.
|
||||||
|
//
|
||||||
|
// This will trigger a refresh of all the shows in the database.
|
||||||
|
action!(
|
||||||
|
app,
|
||||||
|
"refresh",
|
||||||
|
clone!(sender => move |_, _| {
|
||||||
|
gtk::idle_add(clone!(sender => move || {
|
||||||
|
let s: Option<Vec<_>> = None;
|
||||||
|
utils::refresh(s, sender.clone());
|
||||||
|
glib::Continue(false)
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.set_accels_for_action("app.refresh", &["<primary>r"]);
|
||||||
|
|
||||||
glib::Continue(false)
|
// Create the `OPML` import action
|
||||||
});
|
action!(
|
||||||
|
app,
|
||||||
|
"import",
|
||||||
|
clone!(sender, app => move |_, _| {
|
||||||
|
let window = app.get_active_window().expect("Failed to get active window");
|
||||||
|
utils::on_import_clicked(&window, &sender);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the action that shows a `gtk::AboutDialog`
|
||||||
|
action!(
|
||||||
|
app,
|
||||||
|
"about",
|
||||||
|
clone!(app => move |_, _| {
|
||||||
|
let window = app.get_active_window().expect("Failed to get active window");
|
||||||
|
about_dialog(&window);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the quit action
|
||||||
|
action!(app, "quit", clone!(app => move |_, _| app.quit()));
|
||||||
|
app.set_accels_for_action("app.quit", &["<primary>q"]);
|
||||||
|
|
||||||
|
// Bind the hamburger menu button to `F10`
|
||||||
|
app.set_accels_for_action("win.menu", &["F10"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
let window = self.window.clone();
|
|
||||||
let app = self.app_instance.clone();
|
|
||||||
self.app_instance.connect_startup(move |_| {
|
|
||||||
build_ui(&window, &app);
|
|
||||||
});
|
|
||||||
self.setup_timed_callbacks();
|
|
||||||
|
|
||||||
let content = self.content.clone();
|
|
||||||
let headerbar = self.header.clone();
|
|
||||||
let sender = self.sender.clone();
|
|
||||||
let receiver = self.receiver;
|
|
||||||
gtk::idle_add(move || {
|
|
||||||
match receiver.recv_timeout(Duration::from_millis(10)) {
|
|
||||||
Ok(Action::UpdateSources(source)) => {
|
|
||||||
if let Some(s) = source {
|
|
||||||
utils::refresh_feed_wrapper(Some(vec![s]), sender.clone());
|
|
||||||
} else {
|
|
||||||
utils::refresh_feed_wrapper(None, sender.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Action::RefreshAllViews) => content.update(),
|
|
||||||
Ok(Action::RefreshShowsView) => content.update_shows_view(),
|
|
||||||
Ok(Action::RefreshWidget) => content.update_widget(),
|
|
||||||
Ok(Action::RefreshWidgetIfVis) => content.update_widget_if_visible(),
|
|
||||||
Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id),
|
|
||||||
Ok(Action::RefreshEpisodesView) => content.update_episode_view(),
|
|
||||||
Ok(Action::RefreshEpisodesViewBGR) => content.update_episode_view_if_baground(),
|
|
||||||
Ok(Action::ReplaceWidget(pd)) => {
|
|
||||||
if let Err(err) = content.get_shows().replace_widget(pd) {
|
|
||||||
error!("Something went wrong while trying to update the ShowWidget.");
|
|
||||||
error!("Error: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Action::ShowWidgetAnimated) => content.get_shows().switch_widget_animated(),
|
|
||||||
Ok(Action::ShowShowsAnimated) => content.get_shows().switch_podcasts_animated(),
|
|
||||||
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(),
|
|
||||||
Err(_) => (),
|
|
||||||
}
|
|
||||||
|
|
||||||
Continue(true)
|
|
||||||
});
|
|
||||||
|
|
||||||
ApplicationExtManual::run(&self.app_instance, &[]);
|
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,3 +1,5 @@
|
|||||||
|
use gio::MenuModel;
|
||||||
|
use glib;
|
||||||
use gtk;
|
use gtk;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
@@ -5,68 +7,78 @@ use failure::Error;
|
|||||||
use failure::ResultExt;
|
use failure::ResultExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use hammond_data::Source;
|
use crossbeam_channel::Sender;
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::{dbqueries, Source};
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use stacks::Content;
|
use stacks::Content;
|
||||||
|
use utils::{itunes_to_rss, refresh};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
// TODO: split this into smaller
|
||||||
pub struct Header {
|
pub struct Header {
|
||||||
container: gtk::HeaderBar,
|
container: gtk::HeaderBar,
|
||||||
add_toggle: gtk::MenuButton,
|
add_toggle: gtk::MenuButton,
|
||||||
switch: gtk::StackSwitcher,
|
switch: gtk::StackSwitcher,
|
||||||
back_button: gtk::Button,
|
back: gtk::Button,
|
||||||
show_title: gtk::Label,
|
show_title: gtk::Label,
|
||||||
about_button: gtk::ModelButton,
|
|
||||||
update_button: gtk::ModelButton,
|
|
||||||
update_box: gtk::Box,
|
update_box: gtk::Box,
|
||||||
update_label: gtk::Label,
|
update_label: gtk::Label,
|
||||||
update_spinner: gtk::Spinner,
|
update_spinner: gtk::Spinner,
|
||||||
|
menu_button: gtk::MenuButton,
|
||||||
|
app_menu: MenuModel,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Header {
|
impl Default for Header {
|
||||||
fn default() -> Header {
|
fn default() -> Header {
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
|
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
|
||||||
|
let menus = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/menus.ui");
|
||||||
|
|
||||||
let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
|
let header = builder.get_object("headerbar").unwrap();
|
||||||
let add_toggle: gtk::MenuButton = builder.get_object("add_toggle").unwrap();
|
let add_toggle = builder.get_object("add_toggle").unwrap();
|
||||||
let switch: gtk::StackSwitcher = builder.get_object("switch").unwrap();
|
let switch = builder.get_object("switch").unwrap();
|
||||||
let back_button: gtk::Button = builder.get_object("back_button").unwrap();
|
let back = builder.get_object("back").unwrap();
|
||||||
let show_title: gtk::Label = builder.get_object("show_title").unwrap();
|
let show_title = builder.get_object("show_title").unwrap();
|
||||||
let update_button: gtk::ModelButton = builder.get_object("update_button").unwrap();
|
let update_box = builder.get_object("update_notification").unwrap();
|
||||||
let update_box: gtk::Box = builder.get_object("update_notification").unwrap();
|
let update_label = builder.get_object("update_label").unwrap();
|
||||||
let update_label: gtk::Label = builder.get_object("update_label").unwrap();
|
let update_spinner = builder.get_object("update_spinner").unwrap();
|
||||||
let update_spinner: gtk::Spinner = builder.get_object("update_spinner").unwrap();
|
let menu_button = builder.get_object("menu_button").unwrap();
|
||||||
let about_button: gtk::ModelButton = builder.get_object("about_button").unwrap();
|
let app_menu = menus.get_object("menu").unwrap();
|
||||||
|
|
||||||
Header {
|
Header {
|
||||||
container: header,
|
container: header,
|
||||||
add_toggle,
|
add_toggle,
|
||||||
switch,
|
switch,
|
||||||
back_button,
|
back,
|
||||||
show_title,
|
show_title,
|
||||||
about_button,
|
|
||||||
update_button,
|
|
||||||
update_box,
|
update_box,
|
||||||
update_label,
|
update_label,
|
||||||
update_spinner,
|
update_spinner,
|
||||||
|
menu_button,
|
||||||
|
app_menu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Refactor components into smaller state machines
|
// TODO: Refactor components into smaller state machines
|
||||||
impl Header {
|
impl Header {
|
||||||
pub fn new(content: &Content, window: >k::Window, sender: Sender<Action>) -> Header {
|
pub fn new(
|
||||||
|
content: &Content,
|
||||||
|
window: >k::ApplicationWindow,
|
||||||
|
sender: &Sender<Action>,
|
||||||
|
) -> Header {
|
||||||
let h = Header::default();
|
let h = Header::default();
|
||||||
h.init(content, window, sender);
|
h.init(content, window, &sender);
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&self, content: &Content, window: >k::Window, sender: Sender<Action>) {
|
pub fn init(
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
|
&self,
|
||||||
|
content: &Content,
|
||||||
|
window: >k::ApplicationWindow,
|
||||||
|
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 add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
|
||||||
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
|
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
|
||||||
@@ -75,55 +87,45 @@ impl Header {
|
|||||||
self.switch.set_stack(&content.get_stack());
|
self.switch.set_stack(&content.get_stack());
|
||||||
|
|
||||||
new_url.connect_changed(clone!(add_button => move |url| {
|
new_url.connect_changed(clone!(add_button => move |url| {
|
||||||
if let Err(err) = on_url_change(url, &result_label, &add_button) {
|
on_url_change(url, &result_label, &add_button)
|
||||||
error!("Error: {}", err);
|
.map_err(|err| error!("Error: {}", err))
|
||||||
}
|
.ok();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
|
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
|
||||||
if let Err(err) = on_add_bttn_clicked(&new_url, sender.clone()) {
|
on_add_bttn_clicked(&new_url, sender.clone())
|
||||||
error!("Error: {}", err);
|
.map_err(|err| error!("Error: {}", err))
|
||||||
}
|
.ok();
|
||||||
add_popover.hide();
|
add_popover.hide();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
self.add_toggle.set_popover(&add_popover);
|
self.add_toggle.set_popover(&add_popover);
|
||||||
|
|
||||||
self.update_button
|
|
||||||
.connect_clicked(clone!(sender => move |_| {
|
|
||||||
sender
|
|
||||||
.send(Action::UpdateSources(None))
|
|
||||||
.expect("Action channel blew up.");
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.about_button
|
|
||||||
.connect_clicked(clone!(window => move |_| {
|
|
||||||
about_dialog(&window);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Add the Headerbar to the window.
|
// Add the Headerbar to the window.
|
||||||
window.set_titlebar(&self.container);
|
window.set_titlebar(&self.container);
|
||||||
|
|
||||||
let switch = &self.switch;
|
let switch = &self.switch;
|
||||||
let add_toggle = &self.add_toggle;
|
let add_toggle = &self.add_toggle;
|
||||||
let show_title = &self.show_title;
|
let show_title = &self.show_title;
|
||||||
self.back_button.connect_clicked(
|
self.back.connect_clicked(
|
||||||
clone!(switch, add_toggle, show_title, sender => move |back| {
|
clone!(switch, add_toggle, show_title, sender => move |back| {
|
||||||
switch.show();
|
switch.show();
|
||||||
add_toggle.show();
|
add_toggle.show();
|
||||||
back.hide();
|
back.hide();
|
||||||
show_title.hide();
|
show_title.hide();
|
||||||
if let Err(err) = sender.send(Action::ShowShowsAnimated) {
|
sender.send(Action::ShowShowsAnimated)
|
||||||
error!("Action channel blew up: {}", err);
|
.map_err(|err| error!("Action Sender: {}", err))
|
||||||
}
|
.ok();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self.menu_button.set_menu_model(Some(&self.app_menu));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn switch_to_back(&self, title: &str) {
|
pub fn switch_to_back(&self, title: &str) {
|
||||||
self.switch.hide();
|
self.switch.hide();
|
||||||
self.add_toggle.hide();
|
self.add_toggle.hide();
|
||||||
self.back_button.show();
|
self.back.show();
|
||||||
self.set_show_title(title);
|
self.set_show_title(title);
|
||||||
self.show_title.show();
|
self.show_title.show();
|
||||||
}
|
}
|
||||||
@@ -131,7 +133,7 @@ impl Header {
|
|||||||
pub fn switch_to_normal(&self) {
|
pub fn switch_to_normal(&self) {
|
||||||
self.switch.show();
|
self.switch.show();
|
||||||
self.add_toggle.show();
|
self.add_toggle.show();
|
||||||
self.back_button.hide();
|
self.back.hide();
|
||||||
self.show_title.hide();
|
self.show_title.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,19 +154,35 @@ impl Header {
|
|||||||
self.update_spinner.hide();
|
self.update_spinner.hide();
|
||||||
self.update_label.hide();
|
self.update_label.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn open_menu(&self) {
|
||||||
|
self.menu_button.clicked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: THIS ALSO SUCKS!
|
||||||
fn on_add_bttn_clicked(entry: >k::Entry, sender: Sender<Action>) -> Result<(), Error> {
|
fn on_add_bttn_clicked(entry: >k::Entry, sender: Sender<Action>) -> Result<(), Error> {
|
||||||
let url = entry.get_text().unwrap_or_default();
|
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.")?;
|
let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?;
|
||||||
entry.set_text("");
|
entry.set_text("");
|
||||||
|
|
||||||
sender
|
gtk::idle_add(move || {
|
||||||
.send(Action::UpdateSources(Some(source)))
|
refresh(Some(vec![source.clone()]), sender.clone());
|
||||||
.context("App channel blew up.")?;
|
glib::Continue(false)
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: THIS SUCKS!
|
||||||
fn on_url_change(
|
fn on_url_change(
|
||||||
entry: >k::Entry,
|
entry: >k::Entry,
|
||||||
result: >k::Label,
|
result: >k::Label,
|
||||||
@@ -203,38 +221,3 @@ fn on_url_change(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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",
|
|
||||||
];
|
|
||||||
|
|
||||||
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("A 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");
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-22
@@ -1,5 +1,10 @@
|
|||||||
#![cfg_attr(feature = "cargo-clippy", allow(clone_on_ref_ptr, needless_pass_by_value))]
|
#![cfg_attr(
|
||||||
// #![deny(unused_extern_crates, unused)]
|
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;
|
||||||
extern crate gdk_pixbuf;
|
extern crate gdk_pixbuf;
|
||||||
@@ -16,18 +21,25 @@ extern crate lazy_static;
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate log;
|
extern crate log;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[macro_use]
|
||||||
|
extern crate pretty_assertions;
|
||||||
|
|
||||||
extern crate chrono;
|
extern crate chrono;
|
||||||
extern crate dissolve;
|
extern crate crossbeam_channel;
|
||||||
extern crate hammond_data;
|
extern crate hammond_data;
|
||||||
extern crate hammond_downloader;
|
extern crate hammond_downloader;
|
||||||
|
extern crate html2pango;
|
||||||
extern crate humansize;
|
extern crate humansize;
|
||||||
extern crate loggerv;
|
extern crate loggerv;
|
||||||
extern crate open;
|
extern crate open;
|
||||||
|
extern crate rayon;
|
||||||
|
extern crate regex;
|
||||||
|
extern crate reqwest;
|
||||||
extern crate send_cell;
|
extern crate send_cell;
|
||||||
|
extern crate serde_json;
|
||||||
extern crate url;
|
extern crate url;
|
||||||
// extern crate rayon;
|
|
||||||
|
|
||||||
// use rayon::prelude::*;
|
|
||||||
use log::Level;
|
use log::Level;
|
||||||
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
@@ -51,18 +63,16 @@ macro_rules! clone {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// They do not need to be public
|
mod stacks;
|
||||||
// But it helps when looking at the generated docs.
|
mod widgets;
|
||||||
pub mod views;
|
|
||||||
pub mod widgets;
|
|
||||||
pub mod stacks;
|
|
||||||
|
|
||||||
pub mod headerbar;
|
mod app;
|
||||||
pub mod app;
|
mod headerbar;
|
||||||
|
|
||||||
pub mod utils;
|
mod manager;
|
||||||
pub mod manager;
|
mod settings;
|
||||||
pub mod static_resource;
|
mod static_resource;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
||||||
@@ -74,18 +84,12 @@ fn main() {
|
|||||||
|
|
||||||
// Add custom style
|
// Add custom style
|
||||||
let provider = gtk::CssProvider::new();
|
let provider = gtk::CssProvider::new();
|
||||||
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
|
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/Hammond/gtk/style.css");
|
||||||
gtk::StyleContext::add_provider_for_screen(
|
gtk::StyleContext::add_provider_for_screen(
|
||||||
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
|
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
|
||||||
&provider,
|
&provider,
|
||||||
600,
|
600,
|
||||||
);
|
);
|
||||||
|
|
||||||
// This set's the app to dark mode.
|
|
||||||
// It wiil be in the user's preference later.
|
|
||||||
// Uncomment it to run with the dark theme variant.
|
|
||||||
// let settings = gtk::Settings::get_default().unwrap();
|
|
||||||
// settings.set_property_gtk_application_prefer_dark_theme(true);
|
|
||||||
|
|
||||||
App::new().run();
|
App::new().run();
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-45
@@ -1,17 +1,14 @@
|
|||||||
use failure::Error;
|
use failure::Error;
|
||||||
|
use rayon;
|
||||||
|
|
||||||
// use hammond_data::Episode;
|
// use hammond_data::Episode;
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
use hammond_downloader::downloader::{get_episode, DownloadProgress};
|
use hammond_downloader::downloader::{get_episode, DownloadProgress};
|
||||||
|
|
||||||
use app::Action;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
// use std::sync::atomic::AtomicUsize;
|
// use std::sync::atomic::AtomicUsize;
|
||||||
// use std::path::PathBuf;
|
// use std::path::PathBuf;
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
// This is messy, undocumented and hacky af.
|
// This is messy, undocumented and hacky af.
|
||||||
// I am terrible at writting downloaders and download managers.
|
// I am terrible at writting downloaders and download managers.
|
||||||
@@ -73,51 +70,36 @@ impl DownloadProgress for Progress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> = {
|
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> =
|
||||||
Arc::new(RwLock::new(HashMap::new()))
|
{ Arc::new(RwLock::new(HashMap::new())) };
|
||||||
};
|
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add(id: i32, directory: &str, sender: Sender<Action>) -> Result<(), Error> {
|
pub fn add(id: i32, directory: String) -> Result<(), Error> {
|
||||||
// Create a new `Progress` struct to keep track of dl progress.
|
// Create a new `Progress` struct to keep track of dl progress.
|
||||||
let prog = Arc::new(Mutex::new(Progress::default()));
|
let prog = Arc::new(Mutex::new(Progress::default()));
|
||||||
|
|
||||||
{
|
match ACTIVE_DOWNLOADS.write() {
|
||||||
let mut m = ACTIVE_DOWNLOADS
|
Ok(mut guard) => guard.insert(id, prog.clone()),
|
||||||
.write()
|
Err(err) => return Err(format_err!("ActiveDonwloads: {}.", err)),
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
};
|
||||||
m.insert(id, prog.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = directory.to_owned();
|
DLPOOL.spawn(move || {
|
||||||
thread::spawn(move || {
|
if let Ok(mut episode) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||||
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
|
|
||||||
let pid = episode.podcast_id();
|
|
||||||
let id = episode.rowid();
|
let id = episode.rowid();
|
||||||
|
|
||||||
if let Err(err) = get_episode(&mut episode.into(), dir.as_str(), Some(prog)) {
|
get_episode(&mut episode, directory.as_str(), Some(prog))
|
||||||
error!("Error while trying to download an episode");
|
.map_err(|err| error!("Download Failed: {}", err))
|
||||||
error!("Error: {}", err);
|
.ok();
|
||||||
|
|
||||||
|
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
|
||||||
|
let foo = m.remove(&id);
|
||||||
|
debug!("Removed: {:?}", foo);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
|
||||||
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
|
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
|
||||||
info!("Removed: {:?}", m.remove(&id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// {
|
|
||||||
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
|
|
||||||
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
|
|
||||||
// }
|
|
||||||
// }
|
// }
|
||||||
|
|
||||||
sender
|
|
||||||
.send(Action::RefreshEpisodesView)
|
|
||||||
.expect("Action channel blew up.");
|
|
||||||
sender
|
|
||||||
.send(Action::RefreshWidgetIfSame(pid))
|
|
||||||
.expect("Action channel blew up.");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,17 +110,16 @@ pub fn add(id: i32, directory: &str, sender: Sender<Action>) -> Result<(), Error
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use hammond_data::{Episode, Source};
|
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
use hammond_data::pipeline;
|
use hammond_data::pipeline;
|
||||||
use hammond_data::utils::get_download_folder;
|
use hammond_data::utils::get_download_folder;
|
||||||
|
use hammond_data::{Episode, Source};
|
||||||
|
|
||||||
use hammond_downloader::downloader::get_episode;
|
use hammond_downloader::downloader::get_episode;
|
||||||
|
|
||||||
use std::{thread, time};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::mpsc::channel;
|
use std::{thread, time};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
||||||
@@ -160,21 +141,22 @@ mod tests {
|
|||||||
// Get an episode
|
// Get an episode
|
||||||
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
|
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
|
||||||
|
|
||||||
let (sender, _rx) = channel();
|
|
||||||
|
|
||||||
let download_fold = get_download_folder(&pd.title()).unwrap();
|
let download_fold = get_download_folder(&pd.title()).unwrap();
|
||||||
add(episode.rowid(), download_fold.as_str(), sender).unwrap();
|
let fold2 = download_fold.clone();
|
||||||
|
add(episode.rowid(), download_fold).unwrap();
|
||||||
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
|
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
|
||||||
|
|
||||||
// Give it soem time to download the file
|
// Give it soem time to download the file
|
||||||
thread::sleep(time::Duration::from_secs(20));
|
thread::sleep(time::Duration::from_secs(20));
|
||||||
|
|
||||||
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
|
let final_path = format!("{}/{}.mp3", &fold2, episode.rowid());
|
||||||
assert!(Path::new(&final_path).exists());
|
assert!(Path::new(&final_path).exists());
|
||||||
fs::remove_file(final_path).unwrap();
|
fs::remove_file(final_path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
// This test needs access to local system so we ignore it by default.
|
||||||
|
#[ignore]
|
||||||
fn test_dl_steal_the_stars() {
|
fn test_dl_steal_the_stars() {
|
||||||
let url =
|
let url =
|
||||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
|
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use gio;
|
||||||
|
use gio::{Settings, SettingsExt};
|
||||||
|
use gtk;
|
||||||
|
use gtk::GtkWindowExt;
|
||||||
|
|
||||||
|
use chrono::prelude::*;
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
left: i32,
|
||||||
|
top: i32,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
is_maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowGeometry {
|
||||||
|
pub fn from_window(window: >k::ApplicationWindow) -> WindowGeometry {
|
||||||
|
let position = window.get_position();
|
||||||
|
let size = window.get_size();
|
||||||
|
let left = position.0;
|
||||||
|
let top = position.1;
|
||||||
|
let width = size.0;
|
||||||
|
let height = size.1;
|
||||||
|
let is_maximized = window.is_maximized();
|
||||||
|
|
||||||
|
WindowGeometry {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
is_maximized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_settings(settings: &gio::Settings) -> WindowGeometry {
|
||||||
|
let top = settings.get_int("persist-window-geometry-top");
|
||||||
|
let left = settings.get_int("persist-window-geometry-left");
|
||||||
|
let width = settings.get_int("persist-window-geometry-width");
|
||||||
|
let height = settings.get_int("persist-window-geometry-height");
|
||||||
|
let is_maximized = settings.get_boolean("persist-window-geometry-maximized");
|
||||||
|
|
||||||
|
WindowGeometry {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
is_maximized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, window: >k::ApplicationWindow) {
|
||||||
|
if self.width > 0 && self.height > 0 {
|
||||||
|
window.resize(self.width, self.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_maximized {
|
||||||
|
window.maximize();
|
||||||
|
} else if self.top > 0 && self.left > 0 {
|
||||||
|
window.move_(self.left, self.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&self, settings: &gio::Settings) {
|
||||||
|
settings.set_int("persist-window-geometry-left", self.left);
|
||||||
|
settings.set_int("persist-window-geometry-top", self.top);
|
||||||
|
settings.set_int("persist-window-geometry-width", self.width);
|
||||||
|
settings.set_int("persist-window-geometry-height", self.height);
|
||||||
|
settings.set_boolean("persist-window-geometry-maximized", self.is_maximized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_refresh_interval(settings: &Settings) -> Duration {
|
||||||
|
let time = i64::from(settings.get_int("refresh-interval-time"));
|
||||||
|
let period = settings.get_string("refresh-interval-period").unwrap();
|
||||||
|
|
||||||
|
time_period_to_duration(time, period.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
|
||||||
|
let time = i64::from(settings.get_int("cleanup-age-time"));
|
||||||
|
let period = settings.get_string("cleanup-age-period").unwrap();
|
||||||
|
let duration = time_period_to_duration(time, period.as_str());
|
||||||
|
|
||||||
|
Utc::now() - duration
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
|
||||||
|
match period {
|
||||||
|
"weeks" => Duration::weeks(time),
|
||||||
|
"days" => Duration::days(time),
|
||||||
|
"hours" => Duration::hours(time),
|
||||||
|
"minutes" => Duration::minutes(time),
|
||||||
|
_ => Duration::seconds(time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_period_to_duration() {
|
||||||
|
let time = 2;
|
||||||
|
let week = 604800 * time;
|
||||||
|
let day = 86400 * time;
|
||||||
|
let hour = 3600 * time;
|
||||||
|
let minute = 60 * time;
|
||||||
|
|
||||||
|
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
|
||||||
|
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
|
||||||
|
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
|
||||||
|
assert_eq!(
|
||||||
|
minute,
|
||||||
|
time_period_to_duration(time, "minutes").num_seconds()
|
||||||
|
);
|
||||||
|
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn test_apply_window_geometry() {
|
||||||
|
// gtk::init().expect("Error initializing gtk.");
|
||||||
|
|
||||||
|
// let window = gtk::Window::new(gtk::WindowType::Toplevel);
|
||||||
|
// let _geometry = WindowGeometry {
|
||||||
|
// left: 0,
|
||||||
|
// top: 0,
|
||||||
|
// width: 100,
|
||||||
|
// height: 100,
|
||||||
|
// is_maximized: true
|
||||||
|
// };
|
||||||
|
|
||||||
|
// assert!(!window.is_maximized());
|
||||||
|
|
||||||
|
// window.show();
|
||||||
|
// window.activate();
|
||||||
|
// geometry.apply(&window);
|
||||||
|
|
||||||
|
// assert!(window.is_maximized());
|
||||||
|
// }
|
||||||
@@ -1,94 +1,92 @@
|
|||||||
use gtk;
|
use gtk;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
use failure::Error;
|
use failure::Error;
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use stacks::EpisodeStack;
|
use stacks::{HomeStack, ShowStack};
|
||||||
use stacks::ShowStack;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::cell::RefCell;
|
||||||
use std::sync::mpsc::Sender;
|
use std::rc::Rc;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Content {
|
pub struct Content {
|
||||||
stack: gtk::Stack,
|
stack: gtk::Stack,
|
||||||
shows: Arc<ShowStack>,
|
shows: Rc<RefCell<ShowStack>>,
|
||||||
episodes: Arc<EpisodeStack>,
|
home: Rc<RefCell<HomeStack>>,
|
||||||
sender: Sender<Action>,
|
sender: Sender<Action>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Content {
|
impl Content {
|
||||||
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
|
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
|
||||||
let stack = gtk::Stack::new();
|
let stack = gtk::Stack::new();
|
||||||
let episodes = Arc::new(EpisodeStack::new(sender.clone())?);
|
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
|
||||||
let shows = Arc::new(ShowStack::new(sender.clone())?);
|
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?));
|
||||||
|
|
||||||
stack.add_titled(&episodes.get_stack(), "episodes", "Episodes");
|
stack.add_titled(&home.borrow().get_stack(), "home", "Recent");
|
||||||
stack.add_titled(&shows.get_stack(), "shows", "Shows");
|
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
|
||||||
|
|
||||||
Ok(Content {
|
Ok(Content {
|
||||||
stack,
|
stack,
|
||||||
shows,
|
shows,
|
||||||
episodes,
|
home,
|
||||||
sender,
|
sender,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&self) {
|
pub fn update(&self) {
|
||||||
self.update_episode_view();
|
self.update_home();
|
||||||
self.update_shows_view();
|
self.update_shows();
|
||||||
self.update_widget()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Maybe propagate the error?
|
pub fn update_home(&self) {
|
||||||
pub fn update_episode_view(&self) {
|
self.home
|
||||||
if let Err(err) = self.episodes.update() {
|
.borrow_mut()
|
||||||
error!("Something went wrong while trying to update the episode view.");
|
.update()
|
||||||
error!("Error: {}", err);
|
.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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_episode_view_if_baground(&self) {
|
fn update_shows(&self) {
|
||||||
if self.stack.get_visible_child_name() != Some("episodes".into()) {
|
self.shows
|
||||||
self.update_episode_view();
|
.borrow_mut()
|
||||||
}
|
.update()
|
||||||
|
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_shows_view(&self) {
|
pub fn update_shows_view(&self) {
|
||||||
if let Err(err) = self.shows.update_podcasts() {
|
self.shows
|
||||||
error!("Something went wrong while trying to update the ShowsView.");
|
.borrow_mut()
|
||||||
error!("Error: {}", err);
|
.update()
|
||||||
}
|
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||||
}
|
.ok();
|
||||||
|
|
||||||
pub fn update_widget(&self) {
|
|
||||||
if let Err(err) = self.shows.update_widget() {
|
|
||||||
error!("Something went wrong while trying to update the Show Widget.");
|
|
||||||
error!("Error: {}", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_widget_if_same(&self, pid: i32) {
|
pub fn update_widget_if_same(&self, pid: i32) {
|
||||||
if let Err(err) = self.shows.update_widget_if_same(pid) {
|
let pop = self.shows.borrow().populated();
|
||||||
error!("Something went wrong while trying to update the Show Widget.");
|
pop.borrow_mut()
|
||||||
error!("Error: {}", err);
|
.update_widget_if_same(pid)
|
||||||
}
|
.map_err(|err| error!("Failed to update ShowsWidget: {}", err))
|
||||||
}
|
.ok();
|
||||||
|
|
||||||
pub fn update_widget_if_visible(&self) {
|
|
||||||
if self.stack.get_visible_child_name() == Some("shows".to_string())
|
|
||||||
&& self.shows.get_stack().get_visible_child_name() == Some("widget".to_string())
|
|
||||||
{
|
|
||||||
self.update_widget();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_stack(&self) -> gtk::Stack {
|
pub fn get_stack(&self) -> gtk::Stack {
|
||||||
self.stack.clone()
|
self.stack.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_shows(&self) -> Arc<ShowStack> {
|
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
|
||||||
self.shows.clone()
|
self.shows.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_visible_stack(&self) -> Option<gtk::Stack> {
|
||||||
|
self.stack.get_visible_child().and_then(|w| w.downcast::<gtk::Stack>().ok())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
use gtk;
|
|
||||||
use gtk::Cast;
|
|
||||||
use gtk::prelude::*;
|
|
||||||
|
|
||||||
use failure::Error;
|
|
||||||
|
|
||||||
use views::{EmptyView, EpisodesView};
|
|
||||||
|
|
||||||
use app::Action;
|
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EpisodeStack {
|
|
||||||
stack: gtk::Stack,
|
|
||||||
sender: Sender<Action>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EpisodeStack {
|
|
||||||
pub fn new(sender: Sender<Action>) -> Result<EpisodeStack, Error> {
|
|
||||||
let episodes = EpisodesView::new(sender.clone())?;
|
|
||||||
let empty = EmptyView::new();
|
|
||||||
let stack = gtk::Stack::new();
|
|
||||||
|
|
||||||
stack.add_named(&episodes.container, "episodes");
|
|
||||||
stack.add_named(&empty.container, "empty");
|
|
||||||
|
|
||||||
if episodes.is_empty() {
|
|
||||||
stack.set_visible_child_name("empty");
|
|
||||||
} else {
|
|
||||||
stack.set_visible_child_name("episodes");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(EpisodeStack { stack, sender })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look into refactoring to a state-machine.
|
|
||||||
pub fn update(&self) -> Result<(), Error> {
|
|
||||||
let old = self.stack
|
|
||||||
.get_child_by_name("episodes")
|
|
||||||
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
|
|
||||||
.downcast::<gtk::Box>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&old));
|
|
||||||
|
|
||||||
let scrolled_window = old.get_children()
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| format_err!("Box container has no childs."))?
|
|
||||||
.clone()
|
|
||||||
.downcast::<gtk::ScrolledWindow>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
|
|
||||||
|
|
||||||
let eps = EpisodesView::new(self.sender.clone())?;
|
|
||||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
|
||||||
scrolled_window
|
|
||||||
.get_vadjustment()
|
|
||||||
.map(|x| eps.set_vadjustment(&x));
|
|
||||||
|
|
||||||
self.stack.remove(&old);
|
|
||||||
self.stack.add_named(&eps.container, "episodes");
|
|
||||||
|
|
||||||
if eps.is_empty() {
|
|
||||||
self.stack.set_visible_child_name("empty");
|
|
||||||
} else {
|
|
||||||
self.stack.set_visible_child_name("episodes");
|
|
||||||
}
|
|
||||||
|
|
||||||
old.destroy();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_stack(&self) -> gtk::Stack {
|
|
||||||
self.stack.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use gtk;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::StackTransitionType;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[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,7 +1,9 @@
|
|||||||
mod content;
|
mod content;
|
||||||
mod episode;
|
mod home;
|
||||||
|
mod populated;
|
||||||
mod show;
|
mod show;
|
||||||
|
|
||||||
pub use self::content::Content;
|
pub use self::content::Content;
|
||||||
pub use self::episode::EpisodeStack;
|
pub use self::home::HomeStack;
|
||||||
pub use self::show::ShowStack;
|
pub use self::populated::{PopulatedStack, PopulatedState};
|
||||||
|
pub use self::show::{ShowStack, ShowState};
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
use gtk;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::StackTransitionType;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
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::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-153
@@ -1,180 +1,94 @@
|
|||||||
use gtk;
|
use gtk;
|
||||||
use gtk::Cast;
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
use failure::Error;
|
use failure::Error;
|
||||||
|
use hammond_data::dbqueries::is_podcasts_populated;
|
||||||
use hammond_data::Podcast;
|
|
||||||
use hammond_data::dbqueries;
|
|
||||||
|
|
||||||
use views::{EmptyView, ShowsPopulated};
|
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use widgets::ShowWidget;
|
use stacks::PopulatedStack;
|
||||||
|
use utils::get_ignored_shows;
|
||||||
|
use widgets::EmptyView;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::cell::RefCell;
|
||||||
use std::sync::mpsc::Sender;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ShowState {
|
||||||
|
Populated,
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ShowStack {
|
pub struct ShowStack {
|
||||||
|
empty: EmptyView,
|
||||||
|
populated: Rc<RefCell<PopulatedStack>>,
|
||||||
stack: gtk::Stack,
|
stack: gtk::Stack,
|
||||||
|
state: ShowState,
|
||||||
sender: Sender<Action>,
|
sender: Sender<Action>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShowStack {
|
impl ShowStack {
|
||||||
pub fn new(sender: Sender<Action>) -> Result<ShowStack, Error> {
|
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 stack = gtk::Stack::new();
|
||||||
|
let state = ShowState::Empty;
|
||||||
|
|
||||||
let show = ShowStack {
|
stack.add_named(&populated.borrow().container(), "populated");
|
||||||
|
stack.add_named(&empty.container, "empty");
|
||||||
|
|
||||||
|
let mut show = ShowStack {
|
||||||
|
empty,
|
||||||
|
populated,
|
||||||
stack,
|
stack,
|
||||||
sender: sender.clone(),
|
state,
|
||||||
|
sender,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pop = ShowsPopulated::new(sender.clone())?;
|
show.determine_state()?;
|
||||||
let widget = ShowWidget::default();
|
|
||||||
let empty = EmptyView::new();
|
|
||||||
|
|
||||||
show.stack.add_named(&pop.container, "podcasts");
|
|
||||||
show.stack.add_named(&widget.container, "widget");
|
|
||||||
show.stack.add_named(&empty.container, "empty");
|
|
||||||
|
|
||||||
if pop.is_empty() {
|
|
||||||
show.stack.set_visible_child_name("empty")
|
|
||||||
} else {
|
|
||||||
show.stack.set_visible_child_name("podcasts")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(show)
|
Ok(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub fn update(&self) {
|
|
||||||
// self.update_widget();
|
|
||||||
// self.update_podcasts();
|
|
||||||
// }
|
|
||||||
|
|
||||||
pub fn update_podcasts(&self) -> Result<(), Error> {
|
|
||||||
let vis = self.stack
|
|
||||||
.get_visible_child_name()
|
|
||||||
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
|
|
||||||
|
|
||||||
let old = self.stack
|
|
||||||
.get_child_by_name("podcasts")
|
|
||||||
.ok_or_else(|| format_err!("Faild to get \"podcasts\" child from the stack."))?
|
|
||||||
.downcast::<gtk::Box>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&old));
|
|
||||||
|
|
||||||
let scrolled_window = old.get_children()
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| format_err!("Box container has no childs."))?
|
|
||||||
.clone()
|
|
||||||
.downcast::<gtk::ScrolledWindow>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
|
|
||||||
|
|
||||||
let pop = ShowsPopulated::new(self.sender.clone())?;
|
|
||||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
|
||||||
scrolled_window
|
|
||||||
.get_vadjustment()
|
|
||||||
.map(|x| pop.set_vadjustment(&x));
|
|
||||||
|
|
||||||
self.stack.remove(&old);
|
|
||||||
self.stack.add_named(&pop.container, "podcasts");
|
|
||||||
|
|
||||||
if pop.is_empty() {
|
|
||||||
self.stack.set_visible_child_name("empty");
|
|
||||||
} else if vis != "empty" {
|
|
||||||
self.stack.set_visible_child_name(&vis);
|
|
||||||
} else {
|
|
||||||
self.stack.set_visible_child_name("podcasts");
|
|
||||||
}
|
|
||||||
|
|
||||||
old.destroy();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_widget(&self, pd: Arc<Podcast>) -> Result<(), Error> {
|
|
||||||
let old = self.stack
|
|
||||||
.get_child_by_name("widget")
|
|
||||||
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?
|
|
||||||
.downcast::<gtk::Box>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&old));
|
|
||||||
|
|
||||||
let new = ShowWidget::new(pd, self.sender.clone());
|
|
||||||
// Each composite ShowWidget is a gtkBox with the Podcast.id encoded in the gtk::Widget
|
|
||||||
// name. It's a hack since we can't yet subclass GObject easily.
|
|
||||||
let oldid = WidgetExt::get_name(&old);
|
|
||||||
let newid = WidgetExt::get_name(&new.container);
|
|
||||||
debug!("Old widget Name: {:?}\nNew widget Name: {:?}", oldid, newid);
|
|
||||||
|
|
||||||
// Only copy the old scrollbar if both widget's represent the same podcast.
|
|
||||||
if newid == oldid {
|
|
||||||
let scrolled_window = old.get_children()
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| format_err!("Box container has no childs."))?
|
|
||||||
.clone()
|
|
||||||
.downcast::<gtk::ScrolledWindow>()
|
|
||||||
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
|
|
||||||
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
|
|
||||||
|
|
||||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
|
||||||
scrolled_window
|
|
||||||
.get_vadjustment()
|
|
||||||
.map(|x| new.set_vadjustment(&x));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.stack.remove(&old);
|
|
||||||
self.stack.add_named(&new.container, "widget");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_widget(&self) -> Result<(), Error> {
|
|
||||||
let vis = self.stack
|
|
||||||
.get_visible_child_name()
|
|
||||||
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
|
|
||||||
let old = self.stack
|
|
||||||
.get_child_by_name("widget")
|
|
||||||
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
|
|
||||||
|
|
||||||
let id = WidgetExt::get_name(&old);
|
|
||||||
if id == Some("GtkBox".to_string()) || id.is_none() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = id.ok_or_else(|| format_err!("Failed to get widget's name."))?;
|
|
||||||
let pd = dbqueries::get_podcast_from_id(id.parse::<i32>()?)?;
|
|
||||||
self.replace_widget(Arc::new(pd))?;
|
|
||||||
self.stack.set_visible_child_name(&vis);
|
|
||||||
old.destroy();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update widget if it's podcast_id is equal to pid.
|
|
||||||
pub fn update_widget_if_same(&self, pid: i32) -> Result<(), Error> {
|
|
||||||
let old = self.stack
|
|
||||||
.get_child_by_name("widget")
|
|
||||||
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
|
|
||||||
|
|
||||||
let id = WidgetExt::get_name(&old);
|
|
||||||
if id != Some(pid.to_string()) || id.is_none() {
|
|
||||||
debug!("Different widget. Early return");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.update_widget()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn switch_podcasts_animated(&self) {
|
|
||||||
self.stack
|
|
||||||
.set_visible_child_full("podcasts", gtk::StackTransitionType::SlideRight);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn switch_widget_animated(&self) {
|
|
||||||
self.stack
|
|
||||||
.set_visible_child_full("widget", gtk::StackTransitionType::SlideLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_stack(&self) -> gtk::Stack {
|
pub fn get_stack(&self) -> gtk::Stack {
|
||||||
self.stack.clone()
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ use gio::{resources_register, Error, Resource};
|
|||||||
use glib::Bytes;
|
use glib::Bytes;
|
||||||
|
|
||||||
pub fn init() -> Result<(), Error> {
|
pub fn init() -> Result<(), Error> {
|
||||||
// load the gresource binary at build time and include/link it into the final binary.
|
// load the gresource binary at build time and include/link it into the final
|
||||||
|
// binary.
|
||||||
let res_bytes = include_bytes!("../resources/resources.gresource");
|
let res_bytes = include_bytes!("../resources/resources.gresource");
|
||||||
|
|
||||||
// Create Resource it will live as long the value lives.
|
// Create Resource it will live as long the value lives.
|
||||||
let gbytes = Bytes::from_static(res_bytes.as_ref());
|
let gbytes = Bytes::from_static(res_bytes.as_ref());
|
||||||
let resource = Resource::new_from_data(&gbytes)?;
|
let resource = Resource::new_from_data(&gbytes)?;
|
||||||
|
|
||||||
// Register the resource so It wont be dropped and will continue to live in memory.
|
// Register the resource so It wont be dropped and will continue to live in
|
||||||
|
// memory.
|
||||||
resources_register(&resource);
|
resources_register(&resource);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+393
-80
@@ -1,83 +1,232 @@
|
|||||||
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
||||||
|
|
||||||
use failure::Error;
|
use gdk::FrameClockExt;
|
||||||
use gdk_pixbuf::Pixbuf;
|
use gdk_pixbuf::Pixbuf;
|
||||||
|
use glib;
|
||||||
|
use gtk;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{IsA, Widget};
|
||||||
|
|
||||||
|
use chrono::prelude::*;
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use failure::Error;
|
||||||
|
use rayon;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest;
|
||||||
use send_cell::SendCell;
|
use send_cell::SendCell;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
// use hammond_data::feed;
|
// use hammond_data::feed;
|
||||||
use hammond_data::{PodcastCoverQuery, Source};
|
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
|
use hammond_data::opml;
|
||||||
use hammond_data::pipeline;
|
use hammond_data::pipeline;
|
||||||
|
use hammond_data::utils::checkup;
|
||||||
|
use hammond_data::Source;
|
||||||
use hammond_downloader::downloader;
|
use hammond_downloader::downloader;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::{Mutex, RwLock};
|
use std::sync::mpsc::channel;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
|
|
||||||
pub fn refresh_feed_wrapper(source: Option<Vec<Source>>, sender: Sender<Action>) {
|
/// Lazy evaluates and loads widgets to the parent `container` widget.
|
||||||
if let Err(err) = refresh_feed(source, sender) {
|
///
|
||||||
error!("An error occured while trying to update the feeds.");
|
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
|
||||||
error!("Error: {}", err);
|
/// 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`.
|
/// Update the rss feed(s) originating from `source`.
|
||||||
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
|
/// 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.
|
/// When It's done,it queues up a `RefreshViews` action.
|
||||||
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
|
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
|
||||||
sender.send(Action::HeaderBarShowUpdateIndicator)?;
|
where
|
||||||
|
S: IntoIterator<Item = Source> + Send + 'static,
|
||||||
|
{
|
||||||
|
sender
|
||||||
|
.send(Action::HeaderBarShowUpdateIndicator)
|
||||||
|
.map_err(|err| error!("Action Sender: {}", err))
|
||||||
|
.ok();
|
||||||
|
|
||||||
thread::spawn(move || {
|
rayon::spawn(move || {
|
||||||
let mut sources = source.unwrap_or_else(|| {
|
if let Some(s) = source {
|
||||||
dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
|
// Refresh only specified feeds
|
||||||
});
|
pipeline::run(s, false)
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
// Work around to improve the feed addition experience.
|
.map_err(|_| error!("Error While trying to update the database."))
|
||||||
// Many times links to rss feeds are just redirects(usually to an https version).
|
.ok();
|
||||||
// Sadly I haven't figured yet a nice way to follow up links redirects without getting
|
|
||||||
// to lifetime hell with futures and hyper.
|
|
||||||
// So the requested refresh is only of 1 feed, and the feed fails to be indexed,
|
|
||||||
// (as a 301 redict would update the source entry and exit), another refresh is run.
|
|
||||||
// For more see hammond_data/src/models/source.rs `fn request_constructor`.
|
|
||||||
// also ping me on irc if or open an issue if you want to tackle it.
|
|
||||||
if sources.len() == 1 {
|
|
||||||
let source = sources.remove(0);
|
|
||||||
let id = source.id();
|
|
||||||
if let Err(err) = pipeline::index_single_source(source, false) {
|
|
||||||
error!("Error While trying to update the database.");
|
|
||||||
error!("Error msg: {}", err);
|
|
||||||
if let Ok(source) = dbqueries::get_source_from_id(id) {
|
|
||||||
if let Err(err) = pipeline::index_single_source(source, false) {
|
|
||||||
error!("Error While trying to update the database.");
|
|
||||||
error!("Error msg: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// This is what would normally run
|
// Refresh all the feeds
|
||||||
if let Err(err) = pipeline::run(sources, false) {
|
dbqueries::get_sources()
|
||||||
error!("Error While trying to update the database.");
|
.map(|s| s.into_iter())
|
||||||
error!("Error msg: {}", err);
|
.and_then(|s| pipeline::run(s, false))
|
||||||
}
|
.map_err(|err| error!("Error: {}", err))
|
||||||
}
|
.ok();
|
||||||
|
};
|
||||||
|
|
||||||
sender
|
sender
|
||||||
.send(Action::HeaderBarHideUpdateIndicator)
|
.send(Action::HeaderBarHideUpdateIndicator)
|
||||||
.expect("Action channel blew up.");
|
.map_err(|err| error!("Action Sender: {}", err))
|
||||||
|
.ok();
|
||||||
sender
|
sender
|
||||||
.send(Action::RefreshAllViews)
|
.send(Action::RefreshAllViews)
|
||||||
.expect("Action channel blew up.");
|
.map_err(|err| error!("Action Sender: {}", err))
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> = {
|
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
|
||||||
RwLock::new(HashMap::new())
|
{ 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,
|
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
|
||||||
@@ -87,48 +236,212 @@ lazy_static! {
|
|||||||
// GObjects do not implement Send trait, so SendCell is a way around that.
|
// 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.
|
// Also lazy_static requires Sync trait, so that's what the mutexes are.
|
||||||
// TODO: maybe use something that would just scale to requested size?
|
// TODO: maybe use something that would just scale to requested size?
|
||||||
pub fn get_pixbuf_from_path(pd: &PodcastCoverQuery, size: u32) -> Result<Pixbuf, Error> {
|
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.
|
||||||
let hashmap = CACHED_PIXBUFS
|
// If there is, a callback will be set so this function will be called again.
|
||||||
.read()
|
// If the download succedes, there should be a quick return from the pixbuf cache_image
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the pixbuf cache mutex."))?;
|
// If it fails another download will be scheduled.
|
||||||
if let Some(px) = hashmap.get(&(pd.id(), size)) {
|
if let Ok(guard) = COVER_DL_REGISTRY.read() {
|
||||||
let m = px.lock()
|
if guard.contains(&podcast_id) {
|
||||||
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
|
let callback = clone!(image => move || {
|
||||||
return Ok(m.clone().into_inner());
|
let _ = set_image_from_path(&image, podcast_id, size);
|
||||||
|
glib::Continue(false)
|
||||||
|
});
|
||||||
|
gtk::timeout_add(250, callback);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let img_path = downloader::cache_image(pd)?;
|
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
|
||||||
let px = Pixbuf::new_from_file_at_scale(&img_path, size as i32, size as i32, true)?;
|
// Check if the requested (cover + size) is already in the chache
|
||||||
let mut hashmap = CACHED_PIXBUFS
|
// and if so do an early return after that.
|
||||||
.write()
|
if let Some(guard) = hashmap.get(&(podcast_id, size)) {
|
||||||
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
|
guard
|
||||||
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
|
.lock()
|
||||||
Ok(px)
|
.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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_import_clicked(window: >k::Window, sender: &Sender<Action>) {
|
||||||
|
use glib::translate::ToGlib;
|
||||||
|
use gtk::{FileChooserAction, FileChooserNative, 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 = FileChooserNative::new(
|
||||||
|
Some("Select the file from which to you want to Import Shows."),
|
||||||
|
Some(window),
|
||||||
|
FileChooserAction::Open,
|
||||||
|
Some("_Import"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use hammond_data::Source;
|
// use hammond_data::Source;
|
||||||
use hammond_data::dbqueries;
|
// use hammond_data::dbqueries;
|
||||||
|
|
||||||
#[test]
|
// #[test]
|
||||||
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
||||||
// to run it.
|
// to run it.
|
||||||
#[ignore]
|
// #[ignore]
|
||||||
fn test_get_pixbuf_from_path() {
|
// Disabled till https://gitlab.gnome.org/World/hammond/issues/56
|
||||||
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
// fn test_set_image_from_path() {
|
||||||
// Create and index a source
|
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||||
let source = Source::from_url(url).unwrap();
|
// Create and index a source
|
||||||
// Copy it's id
|
// let source = Source::from_url(url).unwrap();
|
||||||
let sid = source.id();
|
// Copy it's id
|
||||||
pipeline::run(vec![source], true).unwrap();
|
// let sid = source.id();
|
||||||
|
// pipeline::run(vec![source], true).unwrap();
|
||||||
|
|
||||||
// Get the Podcast
|
// Get the Podcast
|
||||||
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
|
// let img = gtk::Image::new();
|
||||||
let pxbuf = get_pixbuf_from_path(&pd.into(), 256);
|
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
|
||||||
assert!(pxbuf.is_ok());
|
// 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,7 +0,0 @@
|
|||||||
mod shows;
|
|
||||||
mod episodes;
|
|
||||||
mod empty;
|
|
||||||
|
|
||||||
pub use self::empty::EmptyView;
|
|
||||||
pub use self::episodes::EpisodesView;
|
|
||||||
pub use self::shows::ShowsPopulated;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
use failure::Error;
|
|
||||||
use gtk;
|
|
||||||
use gtk::prelude::*;
|
|
||||||
|
|
||||||
use hammond_data::Podcast;
|
|
||||||
use hammond_data::dbqueries;
|
|
||||||
|
|
||||||
use app::Action;
|
|
||||||
use utils::get_pixbuf_from_path;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ShowsPopulated {
|
|
||||||
pub container: gtk::Box,
|
|
||||||
scrolled_window: gtk::ScrolledWindow,
|
|
||||||
flowbox: gtk::FlowBox,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ShowsPopulated {
|
|
||||||
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();
|
|
||||||
|
|
||||||
ShowsPopulated {
|
|
||||||
container,
|
|
||||||
scrolled_window,
|
|
||||||
flowbox,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShowsPopulated {
|
|
||||||
pub fn new(sender: Sender<Action>) -> Result<ShowsPopulated, Error> {
|
|
||||||
let pop = ShowsPopulated::default();
|
|
||||||
pop.init(sender)?;
|
|
||||||
Ok(pop)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(&self, sender: Sender<Action>) -> Result<(), Error> {
|
|
||||||
self.flowbox.connect_child_activated(move |_, child| {
|
|
||||||
if let Err(err) = on_child_activate(child, sender.clone()) {
|
|
||||||
error!(
|
|
||||||
"Something went wrong during flowbox child activation: {}.",
|
|
||||||
err
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Populate the flowbox with the Podcasts.
|
|
||||||
self.populate_flowbox()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate_flowbox(&self) -> Result<(), Error> {
|
|
||||||
let podcasts = dbqueries::get_podcasts()?;
|
|
||||||
|
|
||||||
podcasts
|
|
||||||
.into_iter()
|
|
||||||
.map(|pd| Arc::new(pd))
|
|
||||||
.for_each(|parent| {
|
|
||||||
let flowbox_child = ShowsChild::new(parent);
|
|
||||||
self.flowbox.add(&flowbox_child.child);
|
|
||||||
});
|
|
||||||
self.flowbox.show_all();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.flowbox.get_children().is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set scrolled window vertical adjustment.
|
|
||||||
pub fn set_vadjustment(&self, vadjustment: >k::Adjustment) {
|
|
||||||
self.scrolled_window.set_vadjustment(vadjustment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: Arc<Podcast>) -> ShowsChild {
|
|
||||||
let child = ShowsChild::default();
|
|
||||||
child.init(pd);
|
|
||||||
child
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(&self, pd: Arc<Podcast>) {
|
|
||||||
self.container.set_tooltip_text(pd.title());
|
|
||||||
|
|
||||||
if let Err(err) = self.set_cover(pd.clone()) {
|
|
||||||
error!("Failed to set a cover: {}", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
WidgetExt::set_name(&self.child, &pd.id().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
|
|
||||||
let image = get_pixbuf_from_path(&pd.clone().into(), 256)?;
|
|
||||||
self.cover.set_from_pixbuf(&image);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use gtk;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
// Totally copied it from fractal.
|
||||||
|
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
|
||||||
|
/// Given a `window` create and attach an `gtk::AboutDialog` to it.
|
||||||
|
pub 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",
|
||||||
|
"Zander Brown",
|
||||||
|
];
|
||||||
|
|
||||||
|
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.4");
|
||||||
|
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.connect_response(|dlg, _| dlg.destroy());
|
||||||
|
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ pub struct EmptyView {
|
|||||||
|
|
||||||
impl Default for EmptyView {
|
impl Default for EmptyView {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
|
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_view.ui");
|
||||||
let view: gtk::Box = builder.get_object("empty_view").unwrap();
|
let view: gtk::Box = builder.get_object("empty_view").unwrap();
|
||||||
|
|
||||||
EmptyView { container: view }
|
EmptyView { container: view }
|
||||||
+425
-264
@@ -1,24 +1,24 @@
|
|||||||
use glib;
|
use glib;
|
||||||
use gtk;
|
use gtk;
|
||||||
|
|
||||||
use chrono::prelude::*;
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
use chrono::Duration;
|
use chrono;
|
||||||
|
use chrono::prelude::*;
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
use failure::Error;
|
use failure::Error;
|
||||||
use humansize::{file_size_opts as size_opts, FileSize};
|
use humansize::{file_size_opts as size_opts, FileSize};
|
||||||
use open;
|
use open;
|
||||||
|
|
||||||
use hammond_data::{EpisodeWidgetQuery, Podcast};
|
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
use hammond_data::utils::get_download_folder;
|
use hammond_data::utils::get_download_folder;
|
||||||
|
use hammond_data::EpisodeWidgetQuery;
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use manager;
|
use manager;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::{Arc, Mutex, TryLockError};
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
|
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
|
||||||
@@ -36,266 +36,452 @@ lazy_static! {
|
|||||||
allow_negative: false,
|
allow_negative: false,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
static ref NOW: DateTime<Utc> = Utc::now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct EpisodeWidget {
|
pub struct EpisodeWidget {
|
||||||
pub container: gtk::Box,
|
pub container: gtk::Box,
|
||||||
|
info: InfoLabels,
|
||||||
|
buttons: Buttons,
|
||||||
|
progressbar: gtk::ProgressBar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct InfoLabels {
|
||||||
|
container: gtk::Box,
|
||||||
|
title: gtk::Label,
|
||||||
|
date: gtk::Label,
|
||||||
|
separator1: gtk::Label,
|
||||||
|
duration: gtk::Label,
|
||||||
|
separator2: gtk::Label,
|
||||||
|
local_size: gtk::Label,
|
||||||
|
size_separator: gtk::Label,
|
||||||
|
total_size: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Buttons {
|
||||||
|
container: gtk::ButtonBox,
|
||||||
play: gtk::Button,
|
play: gtk::Button,
|
||||||
download: gtk::Button,
|
download: gtk::Button,
|
||||||
cancel: gtk::Button,
|
cancel: gtk::Button,
|
||||||
title: gtk::Label,
|
desc: gtk::Button,
|
||||||
date: gtk::Label,
|
}
|
||||||
duration: gtk::Label,
|
|
||||||
progress: gtk::ProgressBar,
|
impl InfoLabels {
|
||||||
total_size: gtk::Label,
|
fn init(&self, episode: &EpisodeWidgetQuery) {
|
||||||
local_size: gtk::Label,
|
// Set the title label state.
|
||||||
separator1: gtk::Label,
|
self.set_title(episode);
|
||||||
separator2: gtk::Label,
|
|
||||||
prog_separator: gtk::Label,
|
// Set the date label.
|
||||||
|
self.set_date(episode.epoch());
|
||||||
|
|
||||||
|
// Set the duaration label.
|
||||||
|
self.set_duration(episode.duration());
|
||||||
|
|
||||||
|
// Set the total_size label.
|
||||||
|
self.set_size(episode.length())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_title(&self, episode: &EpisodeWidgetQuery) {
|
||||||
|
self.title.set_text(episode.title());
|
||||||
|
|
||||||
|
if episode.played().is_some() {
|
||||||
|
self.title
|
||||||
|
.get_style_context()
|
||||||
|
.map(|c| c.add_class("dim-label"));
|
||||||
|
} else {
|
||||||
|
self.title
|
||||||
|
.get_style_context()
|
||||||
|
.map(|c| c.remove_class("dim-label"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the date label of the episode widget.
|
||||||
|
fn set_date(&self, epoch: i32) {
|
||||||
|
lazy_static! {
|
||||||
|
static ref NOW: DateTime<Utc> = Utc::now();
|
||||||
|
};
|
||||||
|
|
||||||
|
let ts = Utc.timestamp(i64::from(epoch), 0);
|
||||||
|
|
||||||
|
// If the episode is from a different year, print year as well
|
||||||
|
if NOW.year() != ts.year() {
|
||||||
|
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
|
||||||
|
// Else omit the year from the label
|
||||||
|
} else {
|
||||||
|
self.date.set_text(ts.format("%e %b").to_string().trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the duration label of the episode widget.
|
||||||
|
fn set_duration(&self, seconds: Option<i32>) {
|
||||||
|
// If lenght is provided
|
||||||
|
if let Some(s) = seconds {
|
||||||
|
// Convert seconds to minutes
|
||||||
|
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
|
||||||
|
// If the lenght is 1 or more minutes
|
||||||
|
if minutes != 0 {
|
||||||
|
// Set the label and show them.
|
||||||
|
self.duration.set_text(&format!("{} min", minutes));
|
||||||
|
self.duration.show();
|
||||||
|
self.separator1.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else hide the labels
|
||||||
|
self.separator1.hide();
|
||||||
|
self.duration.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the size label of the episode widget.
|
||||||
|
fn set_size(&self, bytes: Option<i32>) {
|
||||||
|
// Convert the bytes to a String label
|
||||||
|
let size = || -> Option<String> {
|
||||||
|
let s = bytes?;
|
||||||
|
if s == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.file_size(SIZE_OPTS.clone()).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(s) = size() {
|
||||||
|
self.total_size.set_text(&s);
|
||||||
|
self.total_size.show();
|
||||||
|
self.separator2.show();
|
||||||
|
} else {
|
||||||
|
self.total_size.hide();
|
||||||
|
self.separator2.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EpisodeWidget {
|
impl Default for EpisodeWidget {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
|
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 container = builder.get_object("episode_container").unwrap();
|
||||||
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
|
let progressbar = builder.get_object("progress_bar").unwrap();
|
||||||
|
|
||||||
let download: gtk::Button = builder.get_object("download_button").unwrap();
|
let buttons_container = builder.get_object("button_box").unwrap();
|
||||||
let play: gtk::Button = builder.get_object("play_button").unwrap();
|
let download = builder.get_object("download_button").unwrap();
|
||||||
let cancel: gtk::Button = builder.get_object("cancel_button").unwrap();
|
let play = builder.get_object("play_button").unwrap();
|
||||||
|
let cancel = builder.get_object("cancel_button").unwrap();
|
||||||
|
let desc_button = builder.get_object("desc_button").unwrap();
|
||||||
|
|
||||||
let title: gtk::Label = builder.get_object("title_label").unwrap();
|
let info_container = builder.get_object("info_container").unwrap();
|
||||||
let date: gtk::Label = builder.get_object("date_label").unwrap();
|
let title = builder.get_object("title_label").unwrap();
|
||||||
let duration: gtk::Label = builder.get_object("duration_label").unwrap();
|
let date = builder.get_object("date_label").unwrap();
|
||||||
let local_size: gtk::Label = builder.get_object("local_size").unwrap();
|
let duration = builder.get_object("duration_label").unwrap();
|
||||||
let total_size: gtk::Label = builder.get_object("total_size").unwrap();
|
let local_size = builder.get_object("local_size").unwrap();
|
||||||
|
let total_size = builder.get_object("total_size").unwrap();
|
||||||
|
|
||||||
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
|
let separator1 = builder.get_object("separator1").unwrap();
|
||||||
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
|
let separator2 = builder.get_object("separator2").unwrap();
|
||||||
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
|
|
||||||
|
let size_separator = builder.get_object("prog_separator").unwrap();
|
||||||
|
|
||||||
EpisodeWidget {
|
EpisodeWidget {
|
||||||
|
info: InfoLabels {
|
||||||
|
container: info_container,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
separator1,
|
||||||
|
duration,
|
||||||
|
separator2,
|
||||||
|
local_size,
|
||||||
|
total_size,
|
||||||
|
size_separator,
|
||||||
|
},
|
||||||
|
buttons: Buttons {
|
||||||
|
container: buttons_container,
|
||||||
|
play,
|
||||||
|
download,
|
||||||
|
cancel,
|
||||||
|
desc: desc_button,
|
||||||
|
},
|
||||||
|
progressbar,
|
||||||
container,
|
container,
|
||||||
progress,
|
|
||||||
download,
|
|
||||||
play,
|
|
||||||
cancel,
|
|
||||||
title,
|
|
||||||
duration,
|
|
||||||
date,
|
|
||||||
total_size,
|
|
||||||
local_size,
|
|
||||||
separator1,
|
|
||||||
separator2,
|
|
||||||
prog_separator,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EpisodeWidget {
|
impl EpisodeWidget {
|
||||||
pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
|
pub fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Rc<Self> {
|
||||||
let widget = EpisodeWidget::default();
|
let widget = Rc::new(Self::default());
|
||||||
widget.init(episode, sender);
|
widget.info.init(episode);
|
||||||
|
Self::determine_buttons_state(&widget, episode, sender)
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
|
widget
|
||||||
|
.on_desc_clicked(episode, sender)
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
widget
|
widget
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(&self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
|
fn on_desc_clicked(
|
||||||
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
|
&self,
|
||||||
|
episode: &EpisodeWidgetQuery,
|
||||||
// Set the title label state.
|
sender: &Sender<Action>,
|
||||||
self.set_title(&episode);
|
) -> Result<(), Error> {
|
||||||
|
let id = episode.rowid();
|
||||||
// Set the duaration label.
|
if dbqueries::episode_had_description(id)? {
|
||||||
self.set_duration(episode.duration());
|
self.buttons.desc.show();
|
||||||
|
self.buttons
|
||||||
// Set the date label.
|
.desc
|
||||||
self.set_date(episode.epoch());
|
.connect_clicked(clone!(sender => move |_| {
|
||||||
|
sender.send(Action::AboutWidget(id))
|
||||||
// Show or hide the play/delete/download buttons upon widget initialization.
|
.map_err(|err| error!("Action Sender: {}", err))
|
||||||
if let Err(err) = self.show_buttons(episode.local_uri()) {
|
.ok();
|
||||||
debug!("Failed to determine play/download button state.");
|
}));
|
||||||
debug!("Error: {}", err);
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
// Set the size label.
|
|
||||||
if let Err(err) = self.set_total_size(episode.length()) {
|
|
||||||
error!("Failed to set the Size label.");
|
|
||||||
error!("Error: {}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine what the state of the progress bar should be.
|
|
||||||
if let Err(err) = self.determine_progess_bar() {
|
|
||||||
error!("Something went wrong determining the ProgressBar State.");
|
|
||||||
error!("Error: {}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let episode = Arc::new(Mutex::new(episode));
|
|
||||||
|
|
||||||
let title = self.title.clone();
|
|
||||||
self.play
|
|
||||||
.connect_clicked(clone!(episode, sender => move |_| {
|
|
||||||
if let Ok(mut ep) = episode.lock() {
|
|
||||||
if let Err(err) = on_play_bttn_clicked(&mut ep, &title, sender.clone()){
|
|
||||||
error!("Error: {}", err);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.download
|
|
||||||
.connect_clicked(clone!(episode, sender => move |dl| {
|
|
||||||
dl.set_sensitive(false);
|
|
||||||
if let Ok(ep) = episode.lock() {
|
|
||||||
if let Err(err) = on_download_clicked(&ep, sender.clone()) {
|
|
||||||
error!("Download failed to start.");
|
|
||||||
error!("Error: {}", err);
|
|
||||||
} else {
|
|
||||||
info!("Donwload started succesfully.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show or hide the play/delete/download buttons upon widget initialization.
|
|
||||||
fn show_buttons(&self, local_uri: Option<&str>) -> Result<(), Error> {
|
|
||||||
let path = local_uri.ok_or_else(|| format_err!("Path is None"))?;
|
|
||||||
if Path::new(path).exists() {
|
|
||||||
self.download.hide();
|
|
||||||
self.play.show();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the title state.
|
|
||||||
fn set_title(&self, episode: &EpisodeWidgetQuery) {
|
|
||||||
self.title.set_text(episode.title());
|
|
||||||
|
|
||||||
// Grey out the title if the episode is played.
|
|
||||||
if episode.played().is_some() {
|
|
||||||
self.title
|
|
||||||
.get_style_context()
|
|
||||||
.map(|c| c.add_class("dim-label"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the date label depending on the current time.
|
|
||||||
fn set_date(&self, epoch: i32) {
|
|
||||||
let date = Utc.timestamp(i64::from(epoch), 0);
|
|
||||||
if NOW.year() == date.year() {
|
|
||||||
self.date.set_text(date.format("%e %b").to_string().trim());
|
|
||||||
} else {
|
} else {
|
||||||
self.date
|
self.buttons.desc.hide();
|
||||||
.set_text(date.format("%e %b %Y").to_string().trim());
|
Ok(())
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the duration label.
|
|
||||||
fn set_duration(&self, seconds: Option<i32>) -> Option<()> {
|
|
||||||
let minutes = Duration::seconds(seconds?.into()).num_minutes();
|
|
||||||
if minutes == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.duration.set_text(&format!("{} min", minutes));
|
|
||||||
self.duration.show();
|
|
||||||
self.separator1.show();
|
|
||||||
Some(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the Episode label dependings on its size
|
// fn init(widget: Rc<Self>, sender: &Sender<Action>) {}
|
||||||
fn set_total_size(&self, bytes: Option<i32>) -> Result<(), Error> {
|
|
||||||
let size = bytes.ok_or_else(|| format_err!("Size is None."))?;
|
|
||||||
if size == 0 {
|
|
||||||
bail!("Size is 0.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let s = size.file_size(SIZE_OPTS.clone())
|
// InProgress State:
|
||||||
.map_err(|err| format_err!("{}", err))?;
|
// * Show ProgressBar and Cancel Button.
|
||||||
self.total_size.set_text(&s);
|
// * Show `total_size`, `local_size` labels and `size_separator`.
|
||||||
self.total_size.show();
|
// * Hide Download and Play Buttons
|
||||||
self.separator2.show();
|
fn state_prog(&self) {
|
||||||
Ok(())
|
self.progressbar.show();
|
||||||
|
self.buttons.cancel.show();
|
||||||
|
|
||||||
|
self.info.total_size.show();
|
||||||
|
self.info.local_size.show();
|
||||||
|
self.info.size_separator.show();
|
||||||
|
|
||||||
|
self.buttons.play.hide();
|
||||||
|
self.buttons.download.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: REFACTOR ME
|
// Playable State:
|
||||||
// Something Something State-Machine?
|
// * Hide ProgressBar and Cancel, Download Buttons.
|
||||||
fn determine_progess_bar(&self) -> Result<(), Error> {
|
// * Hide `local_size` labels and `size_separator`.
|
||||||
let id = WidgetExt::get_name(&self.container)
|
// * Show Play Button and `total_size` label
|
||||||
.ok_or_else(|| format_err!("Failed to get widget Name"))?
|
fn state_playable(&self) {
|
||||||
.parse::<i32>()?;
|
self.progressbar.hide();
|
||||||
|
self.buttons.cancel.hide();
|
||||||
|
self.buttons.download.hide();
|
||||||
|
self.info.local_size.hide();
|
||||||
|
self.info.size_separator.hide();
|
||||||
|
|
||||||
let active_dl = || -> Result<Option<_>, Error> {
|
self.info.total_size.show();
|
||||||
|
self.buttons.play.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDownload State:
|
||||||
|
// * Hide ProgressBar and Cancel, Play Buttons.
|
||||||
|
// * Hide `local_size` labels and `size_separator`.
|
||||||
|
// * Show Download Button
|
||||||
|
// * Determine `total_size` label state (Comes from `episode.lenght`).
|
||||||
|
fn state_download(&self) {
|
||||||
|
self.progressbar.hide();
|
||||||
|
self.buttons.cancel.hide();
|
||||||
|
self.buttons.play.hide();
|
||||||
|
|
||||||
|
self.info.local_size.hide();
|
||||||
|
self.info.size_separator.hide();
|
||||||
|
|
||||||
|
self.buttons.download.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_progress(&self, local_size: &str, fraction: f64) {
|
||||||
|
self.info.local_size.set_text(local_size);
|
||||||
|
self.progressbar.set_fraction(fraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the state of the `EpisodeWidget`.
|
||||||
|
///
|
||||||
|
/// Function Flowchart:
|
||||||
|
///
|
||||||
|
/// ------------------- --------------
|
||||||
|
/// | Is the Episode | YES | State: |
|
||||||
|
/// | currently being | ----> | InProgress |
|
||||||
|
/// | downloaded? | | |
|
||||||
|
/// ------------------- --------------
|
||||||
|
/// |
|
||||||
|
/// | NO
|
||||||
|
/// |
|
||||||
|
/// \_/
|
||||||
|
/// ------------------- --------------
|
||||||
|
/// | is the episode | YES | State: |
|
||||||
|
/// | downloaded | ----> | Playable |
|
||||||
|
/// | already? | | |
|
||||||
|
/// ------------------- --------------
|
||||||
|
/// |
|
||||||
|
/// | NO
|
||||||
|
/// |
|
||||||
|
/// \_/
|
||||||
|
/// -------------------
|
||||||
|
/// | State: |
|
||||||
|
/// | ToDownload |
|
||||||
|
/// -------------------
|
||||||
|
fn determine_buttons_state(
|
||||||
|
widget: &Rc<Self>,
|
||||||
|
episode: &EpisodeWidgetQuery,
|
||||||
|
sender: &Sender<Action>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// Reset the buttons state no matter the glade file.
|
||||||
|
// This is just to make it easier to port to relm in the future.
|
||||||
|
widget.buttons.cancel.hide();
|
||||||
|
widget.buttons.play.hide();
|
||||||
|
widget.buttons.download.hide();
|
||||||
|
|
||||||
|
// Check if the episode is being downloaded
|
||||||
|
let id = episode.rowid();
|
||||||
|
let active_dl = move || -> Result<Option<_>, Error> {
|
||||||
let m = manager::ACTIVE_DOWNLOADS
|
let m = manager::ACTIVE_DOWNLOADS
|
||||||
.read()
|
.read()
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||||
|
|
||||||
Ok(m.get(&id).cloned())
|
Ok(m.get(&id).cloned())
|
||||||
}()?;
|
};
|
||||||
|
|
||||||
if let Some(prog) = active_dl {
|
// State: InProggress
|
||||||
// FIXME: Document me?
|
if let Some(prog) = active_dl()? {
|
||||||
self.download.hide();
|
// set a callback that will update the state when the download finishes
|
||||||
self.progress.show();
|
let callback = clone!(widget, sender => move || {
|
||||||
self.local_size.show();
|
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() {
|
||||||
self.total_size.show();
|
if !guard.contains_key(&id) {
|
||||||
self.separator2.show();
|
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||||
self.prog_separator.show();
|
Self::determine_buttons_state(&widget, &ep, &sender)
|
||||||
self.cancel.show();
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
|
|
||||||
let progress_bar = self.progress.clone();
|
return glib::Continue(false)
|
||||||
let total_size = self.total_size.clone();
|
}
|
||||||
let local_size = self.local_size.clone();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup a callback that will update the progress bar.
|
glib::Continue(true)
|
||||||
update_progressbar_callback(prog.clone(), id, &progress_bar, &local_size);
|
});
|
||||||
|
gtk::timeout_add(250, callback);
|
||||||
|
|
||||||
|
// Wire the cancel button
|
||||||
|
widget
|
||||||
|
.buttons
|
||||||
|
.cancel
|
||||||
|
.connect_clicked(clone!(prog, widget, sender => move |_| {
|
||||||
|
// Cancel the download
|
||||||
|
if let Ok(mut m) = prog.lock() {
|
||||||
|
m.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel is not instant so we have to wait a bit
|
||||||
|
timeout_add(50, clone!(widget, sender => move || {
|
||||||
|
if let Ok(thing) = active_dl() {
|
||||||
|
if thing.is_none() {
|
||||||
|
// Recalculate the widget state
|
||||||
|
dbqueries::get_episode_widget_from_rowid(id)
|
||||||
|
.map_err(From::from)
|
||||||
|
.and_then(|ep| Self::determine_buttons_state(&widget, &ep, &sender))
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
return glib::Continue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::Continue(true)
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
// Setup a callback that will update the total_size label
|
// Setup a callback that will update the total_size label
|
||||||
// with the http ContentLength header number rather than
|
// with the http ContentLength header number rather than
|
||||||
// relying to the RSS feed.
|
// relying to the RSS feed.
|
||||||
update_total_size_callback(prog.clone(), &total_size);
|
update_total_size_callback(&widget, &prog);
|
||||||
|
|
||||||
self.cancel.connect_clicked(clone!(prog => move |cancel| {
|
// Setup a callback that will update the progress bar.
|
||||||
if let Ok(mut m) = prog.lock() {
|
update_progressbar_callback(&widget, &prog, id);
|
||||||
m.cancel();
|
|
||||||
cancel.set_sensitive(false);
|
// Change the widget layout/state
|
||||||
}
|
widget.state_prog();
|
||||||
}));
|
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State: Playable
|
||||||
|
if episode.local_uri().is_some() {
|
||||||
|
// Change the widget layout/state
|
||||||
|
widget.state_playable();
|
||||||
|
|
||||||
|
// Wire the play button
|
||||||
|
widget
|
||||||
|
.buttons
|
||||||
|
.play
|
||||||
|
.connect_clicked(clone!(widget, sender => move |_| {
|
||||||
|
if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||||
|
on_play_bttn_clicked(&widget, &mut ep, &sender)
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// State: ToDownload
|
||||||
|
// Wire the download button
|
||||||
|
widget
|
||||||
|
.buttons
|
||||||
|
.download
|
||||||
|
.connect_clicked(clone!(widget, sender => move |dl| {
|
||||||
|
// Make the button insensitive so it won't be pressed twice
|
||||||
|
dl.set_sensitive(false);
|
||||||
|
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||||
|
on_download_clicked(&ep, &sender)
|
||||||
|
.and_then(|_| {
|
||||||
|
info!("Donwload started succesfully.");
|
||||||
|
Self::determine_buttons_state(&widget, &ep, &sender)
|
||||||
|
})
|
||||||
|
.map_err(|err| error!("Error: {}", err))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore sensitivity after operations above complete
|
||||||
|
dl.set_sensitive(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Change the widget state into `ToDownload`
|
||||||
|
widget.state_download();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
|
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Result<(), Error> {
|
||||||
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
|
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
|
||||||
let download_fold = get_download_folder(&pd.title().to_owned())?;
|
let download_fold = get_download_folder(&pd.title())?;
|
||||||
|
|
||||||
// Start a new download.
|
// Start a new download.
|
||||||
manager::add(ep.rowid(), &download_fold, sender.clone())?;
|
manager::add(ep.rowid(), download_fold)?;
|
||||||
|
|
||||||
// Update Views
|
// Update Views
|
||||||
sender.send(Action::RefreshEpisodesView)?;
|
sender
|
||||||
sender.send(Action::RefreshWidgetIfVis)?;
|
.send(Action::RefreshEpisodesViewBGR)
|
||||||
|
.map_err(From::from)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_play_bttn_clicked(
|
fn on_play_bttn_clicked(
|
||||||
|
widget: &Rc<EpisodeWidget>,
|
||||||
episode: &mut EpisodeWidgetQuery,
|
episode: &mut EpisodeWidgetQuery,
|
||||||
title: >k::Label,
|
sender: &Sender<Action>,
|
||||||
sender: Sender<Action>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
open_uri(episode.rowid())?;
|
open_uri(episode.rowid())?;
|
||||||
|
episode.set_played_now()?;
|
||||||
|
|
||||||
if episode.set_played_now().is_ok() {
|
widget.info.set_title(&episode);
|
||||||
title.get_style_context().map(|c| c.add_class("dim-label"));
|
sender
|
||||||
sender.send(Action::RefreshEpisodesViewBGR)?;
|
.send(Action::RefreshEpisodesViewBGR)
|
||||||
};
|
.map_err(From::from)
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_uri(rowid: i32) -> Result<(), Error> {
|
fn open_uri(rowid: i32) -> Result<(), Error> {
|
||||||
@@ -313,55 +499,50 @@ fn open_uri(rowid: i32) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup a callback that will update the progress bar.
|
// Setup a callback that will update the progress bar.
|
||||||
|
#[inline]
|
||||||
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
|
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
|
||||||
fn update_progressbar_callback(
|
fn update_progressbar_callback(
|
||||||
prog: Arc<Mutex<manager::Progress>>,
|
widget: &Rc<EpisodeWidget>,
|
||||||
|
prog: &Arc<Mutex<manager::Progress>>,
|
||||||
episode_rowid: i32,
|
episode_rowid: i32,
|
||||||
progress_bar: >k::ProgressBar,
|
|
||||||
local_size: >k::Label,
|
|
||||||
) {
|
) {
|
||||||
timeout_add(
|
let callback = clone!(widget, prog => move || {
|
||||||
400,
|
progress_bar_helper(&widget, &prog, episode_rowid)
|
||||||
clone!(prog, progress_bar, progress_bar, local_size=> move || {
|
.unwrap_or(glib::Continue(false))
|
||||||
progress_bar_helper(prog.clone(), episode_rowid, &progress_bar, &local_size)
|
});
|
||||||
.unwrap_or(glib::Continue(false))
|
timeout_add(100, callback);
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(if_same_then_else)]
|
||||||
fn progress_bar_helper(
|
fn progress_bar_helper(
|
||||||
prog: Arc<Mutex<manager::Progress>>,
|
widget: &Rc<EpisodeWidget>,
|
||||||
|
prog: &Arc<Mutex<manager::Progress>>,
|
||||||
episode_rowid: i32,
|
episode_rowid: i32,
|
||||||
progress_bar: >k::ProgressBar,
|
|
||||||
local_size: >k::Label,
|
|
||||||
) -> Result<glib::Continue, Error> {
|
) -> Result<glib::Continue, Error> {
|
||||||
let (fraction, downloaded) = {
|
let (fraction, downloaded) = match prog.try_lock() {
|
||||||
let m = prog.lock()
|
Ok(guard) => (guard.get_fraction(), guard.get_downloaded()),
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
|
||||||
(m.get_fraction(), m.get_downloaded())
|
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update local_size label
|
|
||||||
downloaded
|
|
||||||
.file_size(SIZE_OPTS.clone())
|
|
||||||
.map_err(|err| format_err!("{}", err))
|
|
||||||
.map(|x| local_size.set_text(&x))?;
|
|
||||||
|
|
||||||
// I hate floating points.
|
// I hate floating points.
|
||||||
// Update the progress_bar.
|
// Update the progress_bar.
|
||||||
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
|
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
|
||||||
progress_bar.set_fraction(fraction);
|
// Update local_size label
|
||||||
|
let size = downloaded
|
||||||
|
.file_size(SIZE_OPTS.clone())
|
||||||
|
.map_err(|err| format_err!("{}", err))?;
|
||||||
|
|
||||||
|
widget.update_progress(&size, fraction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// info!("Fraction: {}", progress_bar.get_fraction());
|
// info!("Fraction: {}", progress_bar.get_fraction());
|
||||||
// info!("Fraction: {}", fraction);
|
// info!("Fraction: {}", fraction);
|
||||||
|
|
||||||
// Check if the download is still active
|
// Check if the download is still active
|
||||||
let active = {
|
let active = match manager::ACTIVE_DOWNLOADS.read() {
|
||||||
let m = manager::ACTIVE_DOWNLOADS
|
Ok(guard) => guard.contains_key(&episode_rowid),
|
||||||
.read()
|
Err(_) => return Err(format_err!("Failed to get a lock on the mutex.")),
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
|
||||||
m.contains_key(&episode_rowid)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fraction >= 1.0) && (!fraction.is_nan()) {
|
if (fraction >= 1.0) && (!fraction.is_nan()) {
|
||||||
@@ -376,33 +557,30 @@ fn progress_bar_helper(
|
|||||||
// Setup a callback that will update the total_size label
|
// Setup a callback that will update the total_size label
|
||||||
// with the http ContentLength header number rather than
|
// with the http ContentLength header number rather than
|
||||||
// relying to the RSS feed.
|
// relying to the RSS feed.
|
||||||
fn update_total_size_callback(prog: Arc<Mutex<manager::Progress>>, total_size: >k::Label) {
|
#[inline]
|
||||||
timeout_add(
|
fn update_total_size_callback(widget: &Rc<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) {
|
||||||
500,
|
let callback = clone!(prog, widget => move || {
|
||||||
clone!(prog, total_size => move || {
|
total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true))
|
||||||
total_size_helper(prog.clone(), &total_size).unwrap_or(glib::Continue(true))
|
});
|
||||||
}),
|
timeout_add(100, callback);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn total_size_helper(
|
fn total_size_helper(
|
||||||
prog: Arc<Mutex<manager::Progress>>,
|
widget: &Rc<EpisodeWidget>,
|
||||||
total_size: >k::Label,
|
prog: &Arc<Mutex<manager::Progress>>,
|
||||||
) -> Result<glib::Continue, Error> {
|
) -> Result<glib::Continue, Error> {
|
||||||
// Get the total_bytes.
|
// Get the total_bytes.
|
||||||
let total_bytes = {
|
let total_bytes = match prog.try_lock() {
|
||||||
let m = prog.lock()
|
Ok(guard) => guard.get_total_size(),
|
||||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
|
||||||
m.get_total_size()
|
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("Total Size: {}", total_bytes);
|
debug!("Total Size: {}", total_bytes);
|
||||||
if total_bytes != 0 {
|
if total_bytes != 0 {
|
||||||
// Update the total_size label
|
// Update the total_size label
|
||||||
total_bytes
|
widget.info.set_size(Some(total_bytes as i32));
|
||||||
.file_size(SIZE_OPTS.clone())
|
|
||||||
.map_err(|err| format_err!("{}", err))
|
|
||||||
.map(|x| total_size.set_text(&x))?;
|
|
||||||
// Do not call again the callback
|
// Do not call again the callback
|
||||||
Ok(glib::Continue(false))
|
Ok(glib::Continue(false))
|
||||||
} else {
|
} else {
|
||||||
@@ -414,20 +592,3 @@ fn total_size_helper(
|
|||||||
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
|
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
|
||||||
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
|
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
|
||||||
// }
|
// }
|
||||||
|
|
||||||
pub fn episodes_listbox(pd: &Podcast, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
|
|
||||||
let episodes = dbqueries::get_pd_episodeswidgets(pd)?;
|
|
||||||
|
|
||||||
let list = gtk::ListBox::new();
|
|
||||||
|
|
||||||
episodes.into_iter().for_each(|ep| {
|
|
||||||
let widget = EpisodeWidget::new(ep, sender.clone());
|
|
||||||
list.add(&widget.container);
|
|
||||||
});
|
|
||||||
|
|
||||||
list.set_vexpand(false);
|
|
||||||
list.set_hexpand(false);
|
|
||||||
list.set_visible(true);
|
|
||||||
list.set_selection_mode(gtk::SelectionMode::None);
|
|
||||||
Ok(list)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use failure::Error;
|
use failure::Error;
|
||||||
|
|
||||||
use gtk;
|
use gtk;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
use hammond_data::EpisodeWidgetQuery;
|
use crossbeam_channel::Sender;
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
|
use hammond_data::EpisodeWidgetQuery;
|
||||||
|
use send_cell::SendCell;
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use utils::get_pixbuf_from_path;
|
use utils::{self, lazy_load_full};
|
||||||
use widgets::EpisodeWidget;
|
use widgets::EpisodeWidget;
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
|
||||||
|
Mutex::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum ListSplit {
|
enum ListSplit {
|
||||||
@@ -22,7 +31,7 @@ enum ListSplit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EpisodesView {
|
pub struct HomeView {
|
||||||
pub container: gtk::Box,
|
pub container: gtk::Box,
|
||||||
scrolled_window: gtk::ScrolledWindow,
|
scrolled_window: gtk::ScrolledWindow,
|
||||||
frame_parent: gtk::Box,
|
frame_parent: gtk::Box,
|
||||||
@@ -38,9 +47,9 @@ pub struct EpisodesView {
|
|||||||
rest_list: gtk::ListBox,
|
rest_list: gtk::ListBox,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EpisodesView {
|
impl Default for HomeView {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
|
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view.ui");
|
||||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||||
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
|
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
|
||||||
@@ -55,7 +64,7 @@ impl Default for EpisodesView {
|
|||||||
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
|
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
|
||||||
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
|
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
|
||||||
|
|
||||||
EpisodesView {
|
HomeView {
|
||||||
container,
|
container,
|
||||||
scrolled_window,
|
scrolled_window,
|
||||||
frame_parent,
|
frame_parent,
|
||||||
@@ -74,88 +83,76 @@ impl Default for EpisodesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: REFACTOR ME
|
// TODO: REFACTOR ME
|
||||||
impl EpisodesView {
|
impl HomeView {
|
||||||
pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
|
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
|
||||||
let view = EpisodesView::default();
|
use self::ListSplit::*;
|
||||||
let episodes = dbqueries::get_episodes_widgets_with_limit(50)?;
|
|
||||||
|
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 now_utc = Utc::now();
|
||||||
|
|
||||||
episodes.into_iter().for_each(|ep| {
|
let view_ = view.clone();
|
||||||
|
let func = move |ep: EpisodeWidgetQuery| {
|
||||||
let epoch = ep.epoch();
|
let epoch = ep.epoch();
|
||||||
let viewep = EpisodesViewWidget::new(ep, sender.clone());
|
let widget = HomeEpisode::new(&ep, &sender);
|
||||||
|
|
||||||
let t = split(&now_utc, i64::from(epoch));
|
match split(&now_utc, i64::from(epoch)) {
|
||||||
match t {
|
Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
|
||||||
ListSplit::Today => {
|
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
|
||||||
view.today_list.add(&viewep.container);
|
Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
|
||||||
}
|
Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
|
||||||
ListSplit::Yday => {
|
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
|
||||||
view.yday_list.add(&viewep.container);
|
|
||||||
}
|
|
||||||
ListSplit::Week => {
|
|
||||||
view.week_list.add(&viewep.container);
|
|
||||||
}
|
|
||||||
ListSplit::Month => {
|
|
||||||
view.month_list.add(&viewep.container);
|
|
||||||
}
|
|
||||||
ListSplit::Rest => {
|
|
||||||
view.rest_list.add(&viewep.container);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
if view.today_list.get_children().is_empty() {
|
let view_ = view.clone();
|
||||||
view.today_box.hide();
|
let callback = move || {
|
||||||
}
|
view_
|
||||||
|
.set_vadjustment()
|
||||||
if view.yday_list.get_children().is_empty() {
|
.map_err(|err| format!("{}", err))
|
||||||
view.yday_box.hide();
|
.ok();
|
||||||
}
|
};
|
||||||
|
|
||||||
if view.week_list.get_children().is_empty() {
|
|
||||||
view.week_box.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if view.month_list.get_children().is_empty() {
|
|
||||||
view.month_box.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if view.rest_list.get_children().is_empty() {
|
|
||||||
view.rest_box.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
lazy_load_full(episodes, func, callback);
|
||||||
view.container.show_all();
|
view.container.show_all();
|
||||||
Ok(view)
|
Ok(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
if !self.today_list.get_children().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.yday_list.get_children().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.week_list.get_children().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.month_list.get_children().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.rest_list.get_children().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set scrolled window vertical adjustment.
|
/// Set scrolled window vertical adjustment.
|
||||||
pub fn set_vadjustment(&self, vadjustment: >k::Adjustment) {
|
fn set_vadjustment(&self) -> Result<(), Error> {
|
||||||
self.scrolled_window.set_vadjustment(vadjustment)
|
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 {
|
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
|
||||||
@@ -175,22 +172,23 @@ fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct EpisodesViewWidget {
|
struct HomeEpisode {
|
||||||
container: gtk::Box,
|
container: gtk::Box,
|
||||||
image: gtk::Image,
|
image: gtk::Image,
|
||||||
|
// FIXME: Change it to `EpisodeWidget` instead of a `Box`?
|
||||||
episode: gtk::Box,
|
episode: gtk::Box,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EpisodesViewWidget {
|
impl Default for HomeEpisode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let builder =
|
let builder =
|
||||||
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
|
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
|
||||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||||
let ep = EpisodeWidget::default();
|
let ep = EpisodeWidget::default();
|
||||||
container.pack_start(&ep.container, true, true, 6);
|
container.pack_start(&ep.container, true, true, 6);
|
||||||
|
|
||||||
EpisodesViewWidget {
|
HomeEpisode {
|
||||||
container,
|
container,
|
||||||
image,
|
image,
|
||||||
episode: ep.container,
|
episode: ep.container,
|
||||||
@@ -198,19 +196,19 @@ impl Default for EpisodesViewWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EpisodesViewWidget {
|
impl HomeEpisode {
|
||||||
fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
|
fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> HomeEpisode {
|
||||||
let builder =
|
let builder =
|
||||||
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
|
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
|
||||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||||
let pid = episode.podcast_id();
|
let pid = episode.podcast_id();
|
||||||
let ep = EpisodeWidget::new(episode, sender.clone());
|
let ep = EpisodeWidget::new(episode, sender);
|
||||||
|
|
||||||
let view = EpisodesViewWidget {
|
let view = HomeEpisode {
|
||||||
container,
|
container,
|
||||||
image,
|
image,
|
||||||
episode: ep.container,
|
episode: ep.container.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
view.init(pid);
|
view.init(pid);
|
||||||
@@ -218,17 +216,14 @@ impl EpisodesViewWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn init(&self, podcast_id: i32) {
|
fn init(&self, podcast_id: i32) {
|
||||||
if let Err(err) = self.set_cover(podcast_id) {
|
self.set_cover(podcast_id)
|
||||||
error!("Failed to set a cover: {}", err)
|
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||||
}
|
.ok();
|
||||||
|
|
||||||
self.container.pack_start(&self.episode, true, true, 6);
|
self.container.pack_start(&self.episode, true, true, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
|
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
|
||||||
let pd = dbqueries::get_podcast_cover_from_id(podcast_id)?;
|
utils::set_image_from_path(&self.image, podcast_id, 64)
|
||||||
let img = get_pixbuf_from_path(&pd, 64)?;
|
|
||||||
self.image.set_from_pixbuf(&img);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
mod show;
|
mod aboutdialog;
|
||||||
|
pub mod appnotif;
|
||||||
|
mod empty;
|
||||||
mod episode;
|
mod episode;
|
||||||
|
mod home_view;
|
||||||
|
mod show;
|
||||||
|
mod shows_view;
|
||||||
|
|
||||||
|
pub use self::aboutdialog::about_dialog;
|
||||||
|
pub use self::empty::EmptyView;
|
||||||
pub use self::episode::EpisodeWidget;
|
pub use self::episode::EpisodeWidget;
|
||||||
|
pub use self::home_view::HomeView;
|
||||||
pub use self::show::ShowWidget;
|
pub use self::show::ShowWidget;
|
||||||
|
pub use self::show::{mark_all_notif, remove_show_notif};
|
||||||
|
pub use self::shows_view::ShowsView;
|
||||||
|
|||||||
+263
-65
@@ -1,20 +1,30 @@
|
|||||||
use dissolve;
|
use glib;
|
||||||
use failure::Error;
|
|
||||||
use gtk;
|
use gtk;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use open;
|
|
||||||
|
|
||||||
use hammond_data::Podcast;
|
use crossbeam_channel::{SendError, Sender};
|
||||||
|
use failure::Error;
|
||||||
|
use html2pango::markup_from_raw;
|
||||||
|
use open;
|
||||||
|
use rayon;
|
||||||
|
use send_cell::SendCell;
|
||||||
|
|
||||||
use hammond_data::dbqueries;
|
use hammond_data::dbqueries;
|
||||||
use hammond_data::utils::{delete_show, replace_extra_spaces};
|
use hammond_data::utils::delete_show;
|
||||||
|
use hammond_data::Podcast;
|
||||||
|
|
||||||
use app::Action;
|
use app::Action;
|
||||||
use utils::get_pixbuf_from_path;
|
use utils::{self, lazy_load};
|
||||||
use widgets::episode::episodes_listbox;
|
use widgets::appnotif::{InAppNotification, UndoState};
|
||||||
|
use widgets::EpisodeWidget;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
|
||||||
|
Mutex::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ShowWidget {
|
pub struct ShowWidget {
|
||||||
@@ -25,15 +35,16 @@ pub struct ShowWidget {
|
|||||||
link: gtk::Button,
|
link: gtk::Button,
|
||||||
settings: gtk::MenuButton,
|
settings: gtk::MenuButton,
|
||||||
unsub: gtk::Button,
|
unsub: gtk::Button,
|
||||||
episodes: gtk::Frame,
|
episodes: gtk::ListBox,
|
||||||
|
podcast_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ShowWidget {
|
impl Default for ShowWidget {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
|
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
|
||||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||||
let episodes: gtk::Frame = builder.get_object("episodes").unwrap();
|
let episodes = builder.get_object("episodes").unwrap();
|
||||||
|
|
||||||
let cover: gtk::Image = builder.get_object("cover").unwrap();
|
let cover: gtk::Image = builder.get_object("cover").unwrap();
|
||||||
let description: gtk::Label = builder.get_object("description").unwrap();
|
let description: gtk::Label = builder.get_object("description").unwrap();
|
||||||
@@ -50,100 +61,287 @@ impl Default for ShowWidget {
|
|||||||
link,
|
link,
|
||||||
settings,
|
settings,
|
||||||
episodes,
|
episodes,
|
||||||
|
podcast_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShowWidget {
|
impl ShowWidget {
|
||||||
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> ShowWidget {
|
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> Rc<ShowWidget> {
|
||||||
let pdw = ShowWidget::default();
|
let mut pdw = ShowWidget::default();
|
||||||
pdw.init(pd, sender);
|
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
|
pdw
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
|
pub fn init(&mut self, pd: &Arc<Podcast>, sender: &Sender<Action>) {
|
||||||
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
|
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
|
||||||
WidgetExt::set_name(&self.container, &pd.id().to_string());
|
|
||||||
|
|
||||||
self.unsub
|
self.unsub
|
||||||
.connect_clicked(clone!(pd, sender => move |bttn| {
|
.connect_clicked(clone!(pd, sender => move |bttn| {
|
||||||
if let Err(err) = on_unsub_button_clicked(pd.clone(), bttn, sender.clone()) {
|
on_unsub_button_clicked(pd.clone(), bttn, &sender);
|
||||||
error!("Error: {}", err);
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
self.setup_listbox(&pd, sender.clone());
|
|
||||||
self.set_description(pd.description());
|
self.set_description(pd.description());
|
||||||
|
self.podcast_id = Some(pd.id());
|
||||||
|
|
||||||
if let Err(err) = self.set_cover(pd.clone()) {
|
self.set_cover(&pd)
|
||||||
error!("Failed to set a cover: {}", err)
|
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||||
}
|
.ok();
|
||||||
|
|
||||||
let link = pd.link().to_owned();
|
let link = pd.link().to_owned();
|
||||||
self.link.set_tooltip_text(Some(link.as_str()));
|
self.link.set_tooltip_text(Some(link.as_str()));
|
||||||
self.link.connect_clicked(move |_| {
|
self.link.connect_clicked(move |_| {
|
||||||
info!("Opening link: {}", &link);
|
info!("Opening link: {}", &link);
|
||||||
if let Err(err) = open::that(&link) {
|
open::that(&link)
|
||||||
error!("Failed to open link: {}", &link);
|
.map_err(|err| error!("Error: {}", err))
|
||||||
error!("Error: {}", err);
|
.map_err(|_| error!("Failed open link: {}", &link))
|
||||||
}
|
.ok();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// Populate the listbox with the shows episodes.
|
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
|
||||||
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
|
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
|
||||||
let listbox = episodes_listbox(pd, sender.clone());
|
|
||||||
listbox.ok().map(|l| self.episodes.add(&l));
|
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.
|
/// Set the show cover.
|
||||||
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
|
fn set_cover(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
|
||||||
let image = get_pixbuf_from_path(&pd.into(), 128)?;
|
utils::set_image_from_path(&self.cover, pd.id(), 256)
|
||||||
self.cover.set_from_pixbuf(&image);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the descripton text.
|
/// Set the descripton text.
|
||||||
fn set_description(&self, text: &str) {
|
fn set_description(&self, text: &str) {
|
||||||
// TODO: Temporary solution until we render html urls/bold/italic probably with markup.
|
self.description.set_markup(&markup_from_raw(text));
|
||||||
let desc = dissolve::strip_html_tags(text).join(" ");
|
}
|
||||||
self.description.set_text(&replace_extra_spaces(&desc));
|
|
||||||
|
/// 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.
|
/// Set scrolled window vertical adjustment.
|
||||||
pub fn set_vadjustment(&self, vadjustment: >k::Adjustment) {
|
fn set_vadjustment(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
|
||||||
self.scrolled_window.set_vadjustment(vadjustment)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_unsub_button_clicked(
|
/// Populate the listbox with the shows episodes.
|
||||||
|
fn populate_listbox(
|
||||||
|
show: &Rc<ShowWidget>,
|
||||||
pd: Arc<Podcast>,
|
pd: Arc<Podcast>,
|
||||||
unsub_button: >k::Button,
|
|
||||||
sender: Sender<Action>,
|
sender: Sender<Action>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
// hack to get away without properly checking for none.
|
use crossbeam_channel::bounded;
|
||||||
// if pressed twice would panic.
|
use crossbeam_channel::TryRecvError::*;
|
||||||
unsub_button.hide();
|
|
||||||
// Spawn a thread so it won't block the ui.
|
let count = dbqueries::get_pd_episodes_count(&pd)?;
|
||||||
thread::spawn(move || {
|
|
||||||
if let Err(err) = delete_show(&pd) {
|
let (sender_, receiver) = bounded(1);
|
||||||
error!("Something went wrong trying to remove {}", pd.title());
|
rayon::spawn(clone!(pd => move || {
|
||||||
error!("Error: {}", err);
|
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.clone()
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
});
|
});
|
||||||
|
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
fn on_unsub_button_clicked(pd: Arc<Podcast>, unsub_button: >k::Button, sender: &Sender<Action>) {
|
||||||
fn on_played_button_clicked(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
|
// 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)?;
|
dbqueries::update_none_to_played_now(pd)?;
|
||||||
sender.send(Action::RefreshWidget)?;
|
// Not all widgets migth have been loaded when the mark_all is hit
|
||||||
Ok(())
|
// 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 title = bar.get_children().remove(0).downcast::<gtk::Label>().ok()?;
|
||||||
|
|
||||||
|
title.get_style_context().map(|c| c.add_class("dim-label"));
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use gtk;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
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::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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Project xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
|
||||||
|
xmlns:foaf="http://xmlns.com/foaf/0.1/"
|
||||||
|
xmlns:gnome="http://api.gnome.org/doap-extensions#"
|
||||||
|
xmlns="http://usefulinc.com/ns/doap#">
|
||||||
|
|
||||||
|
<name xml:lang="en">Hammond</name>
|
||||||
|
<shortdesc xml:lang="en">
|
||||||
|
Modern Podcast Client for the GNOME desktop
|
||||||
|
</shortdesc>
|
||||||
|
<description xml:lang="en">
|
||||||
|
<p>
|
||||||
|
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
|
||||||
|
desktop written in Rust.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
|
||||||
|
<bug-database rdf:resource="https://gitlab.gnome.org/World/hammond/issues" />
|
||||||
|
|
||||||
|
<category rdf:resource="http://api.gnome.org/doap-extensions#apps" />
|
||||||
|
<programming-language>Rust</programming-language>
|
||||||
|
|
||||||
|
<maintainer>
|
||||||
|
<foaf:Person>
|
||||||
|
<foaf:name>Jordan petridis</foaf:name>
|
||||||
|
<foaf:mbox rdf:resource="mailto:jpetridis@gnome.org" />
|
||||||
|
<gnome:userid>jpetridis</gnome:userid>
|
||||||
|
</foaf:Person>
|
||||||
|
</maintainer>
|
||||||
|
</Project>
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
project(
|
project(
|
||||||
'hammond', 'rust',
|
'hammond', 'rust',
|
||||||
version: '0.3.0',
|
version: '0.3.4',
|
||||||
license: 'GPLv3',
|
license: 'GPLv3',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app-id" : "org.gnome.Hammond",
|
"app-id" : "org.gnome.Hammond",
|
||||||
"runtime" : "org.gnome.Platform",
|
"runtime" : "org.gnome.Platform",
|
||||||
"runtime-version" : "master",
|
"runtime-version" : "3.28",
|
||||||
"sdk" : "org.gnome.Sdk",
|
"sdk" : "org.gnome.Sdk",
|
||||||
"sdk-extensions" : [
|
"sdk-extensions" : [
|
||||||
"org.freedesktop.Sdk.Extension.rust-stable"
|
"org.freedesktop.Sdk.Extension.rust-stable"
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
],
|
],
|
||||||
"desktop-file-name-prefix" : "(Nightly) ",
|
"desktop-file-name-prefix" : "(Nightly) ",
|
||||||
"finish-args" : [
|
"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=network",
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--socket=x11",
|
"--socket=x11",
|
||||||
@@ -24,17 +28,19 @@
|
|||||||
"--share=network"
|
"--share=network"
|
||||||
],
|
],
|
||||||
"env" : {
|
"env" : {
|
||||||
"CARGO_HOME" : "/run/build/Hammond/cargo"
|
"CARGO_HOME" : "/run/build/Hammond/cargo",
|
||||||
|
"RUST_BACKTRACE" : "1",
|
||||||
|
"DEBUG" : "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modules" : [
|
"modules" : [
|
||||||
{
|
{
|
||||||
"name" : "Hammond",
|
"name" : "hammond",
|
||||||
"buildsystem" : "meson",
|
"buildsystem" : "meson",
|
||||||
"sources" : [
|
"sources" : [
|
||||||
{
|
{
|
||||||
"type" : "git",
|
"type" : "git",
|
||||||
"url" : "https://gitlab.gnome.org/alatiera/Hammond.git",
|
"url" : "https://gitlab.gnome.org/World/hammond.git",
|
||||||
"branch" : "master"
|
"branch" : "master"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
unstable_features = true
|
unstable_features = true
|
||||||
verbose = false
|
|
||||||
max_width = 100
|
max_width = 100
|
||||||
comment_width = 100
|
comment_width = 100
|
||||||
wrap_comments = true
|
wrap_comments = true
|
||||||
tab_spaces = 4
|
tab_spaces = 4
|
||||||
hard_tabs = false
|
hard_tabs = false
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
write_mode = "Overwrite"
|
|
||||||
condense_wildcard_suffixes = false
|
condense_wildcard_suffixes = false
|
||||||
format_strings = true
|
format_strings = true
|
||||||
normalize_comments = true
|
normalize_comments = true
|
||||||
reorder_imports = true
|
reorder_imports = true
|
||||||
reorder_imported_names = true
|
|
||||||
reorder_imports_in_group = true
|
|
||||||
|
|||||||
+9
-1
@@ -1,5 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
export CARGO_HOME=$1/target/cargo-home
|
export CARGO_HOME=$1/target/cargo-home
|
||||||
|
export RUSTFLAGS="--cfg rayon_unstable"
|
||||||
|
|
||||||
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
|
if [[ $DEBUG = true ]]
|
||||||
|
then
|
||||||
|
echo "DEBUG MODE"
|
||||||
|
cargo build -p hammond-gtk && cp $1/target/debug/hammond-gtk $2
|
||||||
|
else
|
||||||
|
echo "RELEASE MODE"
|
||||||
|
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
|
||||||
|
fi
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
schemadir = os.path.join(os.environ['MESON_INSTALL_PREFIX'], 'share', 'glib-2.0', 'schemas')
|
||||||
|
|
||||||
|
if not os.environ.get('DESTDIR'):
|
||||||
|
print('Compiling gsettings schemas...')
|
||||||
|
subprocess.call(['glib-compile-schemas', schemadir])
|
||||||
@@ -13,6 +13,7 @@ cp -rf hammond-data $DIST
|
|||||||
cp -rf hammond-gtk $DIST
|
cp -rf hammond-gtk $DIST
|
||||||
cp -rf hammond-downloader $DIST
|
cp -rf hammond-downloader $DIST
|
||||||
cp Cargo.toml $DIST
|
cp Cargo.toml $DIST
|
||||||
|
cp Cargo.lock $DIST
|
||||||
cp configure $DIST
|
cp configure $DIST
|
||||||
cp meson.build $DIST
|
cp meson.build $DIST
|
||||||
cp Hammond.doap $DIST
|
cp Hammond.doap $DIST
|
||||||
|
|||||||
Reference in New Issue
Block a user