Compare commits

...

913 Commits

Author SHA1 Message Date
f2ac198831
Merge branch 'master' of gitlab.gnome.org:World/podcasts 2020-06-21 21:15:03 +01:00
Jordan Petridis
abd263f127 Merge branch 'wip/exalm/tnum' into 'master'
Tabular figures; model buttons for speed rate popover

See merge request World/podcasts!134
2020-06-21 18:27:08 +00:00
Alexander Mikhaylenko
9c2d4ac6a7
episode: Use tabular figures
Prevents jumping during downloads.
2020-06-21 21:17:00 +03:00
Alexander Mikhaylenko
4d950ac17e
player: Use tabular figures for duration 2020-06-21 21:17:00 +03:00
Alexander Mikhaylenko
5e688c104c
player_rate: Use tabular figures for speed rate menu
Since gtk::ModelButton allows to set markup, the menu entries can now use
tabular figures.

Make the labels untranslatable, as they are generic enough already, and
being translatable would make even less sense with markup.
2020-06-21 21:17:00 +03:00
Alexander Mikhaylenko
4ce0819c68
player: Use model buttons for rate popover
Also simplify the xml.
2020-06-21 21:16:58 +03:00
Jordan Petridis
f7d9d0555c
meson: keep track of changes in Cargo.toml and lockfile 2020-06-21 18:55:25 +03:00
Jordan Petridis
b754182e0d
cargo fmt 2020-06-21 18:38:29 +03:00
Jordan Petridis
7b3a607b5e
improve test.sh further
Before we were able to call the script with ninja directly,
we were trying to override some of the envvars to fix how/where
cargo artifacts where stored.

This is no longer an issue as ninja is making sure the proper
setup will be met. This is also makes it so that Builder
doesn't rebuild everything since $BUILDDIR was empty before
causing rebuilds of the whole world.
2020-06-21 18:30:48 +03:00
Jordan Petridis
0e47e9c07f
test.sh: run the build proccess multithreaded 2020-06-21 18:21:32 +03:00
Jordan Petridis
c140e5a163
downloader: remove unused dependency on error-chain 2020-06-21 18:12:23 +03:00
Jordan Petridis
0bc379bf42 Merge branch 'alatiera/spelling' into 'master'
Fix spelling of things

See merge request World/podcasts!143
2020-06-21 15:11:41 +00:00
Jordan Petridis
9f0a3a0d9f
Fix spelling of things 2020-06-21 18:10:53 +03:00
Jordan Petridis
355cf9a36c Merge branch 'alatiera/rever-futures-upgrade' into 'master'
Revert 096197cf81

See merge request World/podcasts!144
2020-06-21 15:06:57 +00:00
Jordan Petridis
d9792e99c1
Revert 096197cf81
It breaks the testsuite 'sometime' which is fairly annoying.
The whole testuite setup is crap though and likely needs to be
fixed first.

Will re-revert the changes once a new stable release of the app
is done.
2020-06-21 17:03:36 +03:00
Jordan Petridis
6cb7de7fb3 Merge branch 'more-clone' into 'master'
Use clone! macro in more cases

See merge request World/podcasts!141
2020-06-21 13:38:19 +00:00
Julian Hofer
cb0860cddf
Use clone! macro in more cases 2020-06-20 18:30:44 +03:00
Thibault Martin
6edeb59b16 Update French translation 2020-06-19 17:35:06 +00:00
Jordan Petridis
a245aa73d4 Merge branch 'window-cleanup' into 'master'
Cleanup windows.rs

See merge request World/podcasts!140
2020-06-19 12:00:10 +00:00
Julian Hofer
08be9bdb4e Fix broken window.connect_delete_event
It was never called with the original configuration
2020-06-19 09:51:35 +00:00
Julian Hofer
2ee2181211 Cleanup windows.rs
It shouldn't result in any functional changes, but some unnecessary
parts were removed and windows.rs is now tracked by meson
2020-06-19 09:51:35 +00:00
Jordan Petridis
59e1c7d6f4
cargo update deps 2020-06-19 12:05:03 +03:00
Jordan Petridis
c2aca6e3a0
Flatpak: use the 3.36 runtime
Nightly has moved into fd.o-sdk 20.08 base and the rust-sdk
extension for that base is in flux and I haven't had time to
deal with it yet.
2020-06-19 11:42:44 +03:00
5f39da9273
Added playback speed 1.75, 2.0, 2.25, 2.5, 2.75, 3.0 in player 2020-06-19 00:21:16 +01:00
e16d69737e
Added playback speed 1.75, 2.0, 2.25, 2.5, 2.75, 3.0 to UI 2020-06-19 00:20:41 +01:00
Tim Sabsch
e6e2af38d3 Update German translation 2020-05-17 13:58:11 +00:00
Dz Chen
975697728e Add Chinese (China) translation 2020-04-18 10:36:24 +00:00
Yuri Chornoivan
a2789c9dba Add Ukrainian translation 2020-04-06 16:50:41 +00:00
Jordan Petridis
096197cf81 Merge branch 'futures-upgrade' into 'master'
Upgrade to Futures 0.3

See merge request World/podcasts!139
2020-03-21 20:33:45 +00:00
Julian Hofer
f9d577f596 Convert more functions to "async fn" 2020-03-18 01:09:11 +00:00
Julian Hofer
429356a217 Use tokio main-macro 2020-03-18 01:09:11 +00:00
Julian Hofer
636e2aefde Add tokio features and remove lazy keyword 2020-03-18 01:09:11 +00:00
Julian Hofer
e830589e38 Remove unused imports 2020-03-18 01:09:11 +00:00
Julian Hofer
933ba62f39 Upgrade to Futures 0.3 2020-03-18 01:09:11 +00:00
Goran Vidović
93f7aa4457 Update Croatian translation 2020-03-15 14:53:33 +00:00
Milo Casagrande
3ef45c42fb Update Italian translation 2020-03-06 08:47:09 +00:00
Nathan Follens
071e5978aa Update Dutch translation 2020-02-26 13:31:59 +00:00
Kukuh Syafaat
fba5102705 Update Indonesian translation 2020-02-24 15:33:10 +00:00
Jiri Grönroos
138e308c32 Update Finnish translation 2020-02-23 11:44:49 +00:00
Emin Tufan Çetin
fa8f1a0c8e Update Turkish translation 2020-02-19 07:54:22 +00:00
Balázs Úr
7325c3b4d3 Update Hungarian translation 2020-02-17 22:32:08 +00:00
Jordan Petridis
3e5ddb2aff Merge branch 'switch-clone' into 'master'
Switch to glib::clone macro and fix some typos

See merge request World/podcasts!138
2020-02-17 11:43:19 +00:00
Julian Hofer
d1ebbde778
Switch to glib::clone macro and fix some typos 2020-02-17 13:11:44 +02:00
Anders Jonsson
8a042c5714 Update Swedish translation 2020-02-13 21:39:22 +00:00
Piotr Drąg
7a2c252bbc Update Polish translation 2020-02-09 11:20:36 +00:00
Jordan Petridis
7d212174a6 Merge branch 'synchronize-feed-updates' into 'master'
Synchronize feed updates

Closes #166

See merge request World/podcasts!133
2020-02-08 21:42:04 +00:00
Timofey
685b35cb23 Rename refresh to schedule_refresh 2020-02-08 15:47:27 +00:00
Timofey
1175a54266 Synchronize feed updates 2020-02-08 15:47:27 +00:00
Yuri Gomes
d38064e1c0 Update Brazilian Portuguese translation 2020-02-08 02:46:04 +00:00
Jordan Petridis
59a57a740f Merge branch 'alatiera/updates' into 'master'
Update gtk-rs

See merge request World/podcasts!137
2020-02-07 19:22:28 +00:00
Jordan Petridis
f319851753
Upgrade gtk-rs stack 2020-02-07 20:48:04 +02:00
Jordan Petridis
ce424977c0 cargo update 2020-02-07 18:20:19 +00:00
Jordan Petridis
62c6b0d4cb flatpak: stop setting custom RUSTFLAGS
Builder no longer makes use of them
2020-02-07 18:20:19 +00:00
Daniel Mustieles
7685570da9 Update Spanish translation 2020-02-07 12:20:57 +00:00
Jordan Petridis
ff0c488968 Merge branch 'Remove-warnings' into 'master'
Get rid of warnings

See merge request World/podcasts!136
2020-02-07 12:13:46 +00:00
Julian Hofer
ebc6c8df4d Directly import ClassStruct and InstanceStruct 2020-02-07 11:44:03 +00:00
Julian Hofer
06a2c3ab12 Get rid of clippy warnings 2020-02-07 11:44:03 +00:00
Jordan Petridis
e877da1825 Merge branch 'potfiles-200202' into 'master'
Update POTFILES.in 200202

See merge request World/podcasts!135
2020-02-07 11:33:18 +00:00
Piotr Drąg
0b744b1179 Update POTFILES.in 2020-02-02 13:47:51 +01:00
Jordan Petridis
1041f21724 Merge branch 'now-playing' into 'master'
Adaptive "Now Playing" toolbar

Closes #158

See merge request World/podcasts!131
2020-02-01 14:52:59 +00:00
James Westman
73012f7976 Adaptive "Now Playing" toolbar
The "Now Playing" toolbar now shrinks to fit on small screens. On all
screen sizes, clicking the toolbar reveals a HdyDialog with the same
controls.

Also fixes #158.
2020-02-01 14:01:37 +00:00
Jordan Petridis
a1b4cece7d flatpak: set RUSTFLAGS to none to workaround a cargo bug 2020-02-01 16:00:48 +02:00
Fabio Tomat
ac4cd50929 Update Friulian translation 2020-01-21 10:01:04 +00:00
Kukuh Syafaat
bb5b3846a6 Update Indonesian translation 2020-01-17 03:10:59 +00:00
Piotr Drąg
3a11fbcf18 Update Polish translation 2019-12-25 16:05:48 +00:00
Anders Jonsson
a8da740ada Update Swedish translation 2019-12-22 11:01:49 +00:00
Jordi Mas i Hernandez
082ec0f592 Update Catalan translation 2019-12-16 06:31:10 +00:00
Daniel Mustieles
d49cad0bc0 Update Spanish translation 2019-12-11 11:32:00 +00:00
Rafael Fontenelle
75d8676de5 Update Brazilian Portuguese translation 2019-12-09 13:24:11 +00:00
Jordan Petridis
8228bc8996 Merge branch 'fix-81' into 'master'
Improve show description UI

Closes #81

See merge request World/podcasts!129
2019-12-08 20:02:52 +00:00
James Westman
8081990895 Improve show description UI
Instead of being a scroll window inside a scroll window, the show
description now shows just the first paragraph by default, then displays
a "Read More" button if there is more to the description. Clicking the
button reveals the rest.

Currently, to keep the button from glitching when updating it from the
size-allocate signal, a GtkRevealer with a transition-duration of
1 millisecond is used. It's a hacky workaround but I'm not quite sure
how to do it better.

Fixes #81
2019-12-08 19:26:25 +00:00
Jordan Petridis
eb2d5419f9
CI: fix typo in a9ba3fcbab 2019-12-08 20:53:44 +02:00
Jordan Petridis
a9ba3fcbab
CI: Refactor to use the citemplate
https://gitlab.gnome.org/GNOME/Initiatives/wikis/DevOps-with-Flatpak
2019-12-08 20:45:01 +02:00
Jordan Petridis
bcd610e2e3
cargo fmt 2019-11-18 10:23:59 +02:00
Jordan Petridis
217362fe14 Merge branch 'add-entry-activate' into 'master'
headerbar: Add activate handler to add entry

See merge request World/podcasts!130
2019-10-29 09:47:39 +00:00
James Westman
0e0d860cd2 headerbar: Add activate handler to add entry
This way, pressing Enter after typing/pasting a URL adds the podcast
feed, instead of needing to press the button separately.
2019-10-28 11:57:51 -05:00
Jordan Petridis
a8246a96cd Merge branch 'fix-156' into 'master'
Fix extra window appearing for some podcasts

Closes #156

See merge request World/podcasts!128
2019-10-25 00:28:19 +00:00
James Westman
33d28d1e7b Fix extra window appearing for some podcasts
Fixes #156.
2019-10-24 23:51:20 +00:00
Jordan Petridis
377d7bdf47 Merge branch 'fix-80-c' into 'master'
headerbar: Use Handy widgets

See merge request World/podcasts!127
2019-10-24 21:20:15 +00:00
James Westman
f6eb3dd90e headerbar: Use Handy widgets 2019-10-24 15:46:46 -05:00
Jordan Petridis
f632a66d60 Merge branch 'wip/christopherdavis/release-0.4.7' into 'master'
Release version 0.4.7

See merge request World/podcasts!126
2019-10-23 18:23:20 +00:00
Christopher Davis
ea0bb607b6
Release version 0.4.7 2019-10-23 20:45:37 +03:00
Jordan Petridis
85e15b77d9
fix the test script so it works outside flatpak again 2019-10-23 20:02:23 +03:00
Jordan Petridis
95055dea1d
update cargo deps 2019-10-23 19:22:48 +03:00
Jordan Petridis
630804c5b5
meson: Bump the required version of deps in order to build 2019-10-23 18:40:38 +03:00
Jordan Petridis
6cce4183e9
Flatpak: disable handy introspection generation 2019-10-20 20:25:18 +03:00
Jordan Petridis
35bd0d625c
p-data: Update the user agent 2019-10-20 20:22:36 +03:00
Jordan Petridis
073d00b45b
Update cargo deps 2019-10-20 20:21:32 +03:00
Jordan Petridis
982cb13e7e
shows_view: remove reduntant import 2019-10-20 20:14:11 +03:00
Jordan Petridis
6d3d4c8339
meson: install nightly icon 2019-10-20 20:03:07 +03:00
Jordan Petridis
911e420c24
Flatpak: remove dri access
There was a time where an inline videoplayer was a possibility,
and this would be needed for the GL widget, but it ain't
happening.
2019-10-20 19:40:46 +03:00
Jordan Petridis
e69ff0325e
Flatpak: remove dconf permissions.
Yay 🎉🎉🎉
2019-10-20 19:38:15 +03:00
Jordan Petridis
7c1353a8aa
Flatpak: Add fallback-x11 socket
This makes it so the x11 socket is made available only when
there is no wayland socket available in the system.
2019-10-20 19:35:05 +03:00
Jordan Petridis
4ce9a172b8 Merge branch 'wip/sadiq/small-width' into 'master'
Let the window work on smaller screens

See merge request World/podcasts!122
2019-10-20 16:22:16 +00:00
Mohammed Sadiq
61a7b24084 headerbar: Use a smaller width for entry
So that it fits better on smaller screens
2019-10-19 15:26:29 +00:00
Mohammed Sadiq
8b2ca19f1a episode_widget: Allow reaching smaller widths
So that the window fits fine on smaller screens
2019-10-19 15:26:29 +00:00
Jordan Petridis
deafa8f4d3 Merge branch 'fix-142' into 'master'
Show app icon in PulseAudio settings

Closes #142

See merge request World/podcasts!125
2019-10-19 15:25:59 +00:00
James Westman
c739935335 Show app icon in PulseAudio settings
According to the docs, `gtk::Window::set_default_icon_name` should take care of
this, but it evidently isn't. So we'll use the environment variable method
instead.

Fixes #142
2019-10-19 01:26:46 -05:00
Asier Sarasua Garmendia
34e4a4f483 Add Basque translation 2019-10-18 08:00:51 +00:00
Jordan Petridis
85c485509c Merge branch 'update_mpris_permissions' into 'master'
Remove unnecessary MPRIS Flatpak permissions

See merge request World/podcasts!124
2019-10-10 13:08:12 +00:00
Felix Häcker
026145e0c7 Remove unnecessary MPRIS Flatpak permissions 2019-10-09 21:03:13 +02:00
Jordan Petridis
bb0cf5b547 Merge branch 'update-coc' into 'master'
Update the Code of Conduct to the official GNOME CoC

See merge request World/podcasts!123
2019-10-08 17:37:43 +00:00
Christopher Davis
d8acffa844 Update the Code of Conduct to the official GNOME CoC 2019-10-08 09:55:20 -07:00
Jordan Petridis
a1f1cddcbe Merge branch 'revert-d8496fe4' into 'master'
Revert "Flatpak: use the 3.32 runtime version"

See merge request World/podcasts!115
2019-10-04 12:28:24 +00:00
Christopher Davis
d19612bb3c README: use new nightly and rust-stable locations
freedeskop-sdk is at 19.08 now, and sdk.g.o -> nightly.g.o
2019-10-04 11:53:05 +00:00
Christopher Davis
30e81961e8 Revert "Flatpak: use the 3.32 runtime version"
This reverts commit d8496fe4c4
2019-10-04 11:53:05 +00:00
Jordan Petridis
a9873da802
cargo fmt 2019-10-04 14:52:19 +03:00
Jordan Petridis
6046e62f11
Flatpak: update libhandy version to 0.11 2019-10-04 14:52:19 +03:00
Jordan Petridis
eb032619a0 Merge branch 'fix-link-and-typo' into 'master'
Fix link and typo

See merge request World/podcasts!121
2019-09-29 16:02:43 +00:00
Julian Hofer
d98f0a20a8 Fix link and typo 2019-09-24 21:34:10 +02:00
Jordan Petridis
945b40249c
Do not hardcode deny(warnings) in the source code
When a new version of the compiler introduces a new warning
it makes your perfectly fine older release require manual
patching in order to build again.
2019-09-13 03:05:38 +03:00
Jordan Petridis
1192642811
update cargo deps 2019-09-13 02:55:10 +03:00
Jordan Petridis
70772a61a9
meson.build: stop checking for cargo vendor
Since rust 1.37 release its now part of cargo itself.
2019-09-13 01:24:25 +03:00
Sabri Ünal
66b3f031a5 Update Turkish translation 2019-09-09 06:49:59 +00:00
Jordan Petridis
56a9c115f8 Merge branch 'reset-playback-rate' into 'master'
player:	Playback rate is reset when episode is started

Closes #65

See merge request World/podcasts!116
2019-09-04 14:45:45 +00:00
Julian Hofer
bd10ed93af
player: Playback rate is reset when episode is started
When an episode	is started the "radio_normal"-button is
set to true.
Fixes #65
2019-09-04 17:10:45 +03:00
Jordan Petridis
c3351f01e4 Merge branch 'url-parsing' into 'master'
headerbar: Refactor 'add' styling after url parsing

See merge request World/podcasts!119
2019-09-04 14:04:50 +00:00
FeuRenard
1d13384f6c headerbar: Refactor 'add' styling after url parsing
The current code includes many duplications.

I extract a single parameterized style function
accompanied by two delegate functions.

https://gitlab.gnome.org/World/podcasts/issues/45
2019-09-03 22:11:24 +02:00
Jordan Petridis
598e225b00 Merge branch 'zbrown/subclass-gtkapp' into 'master'
Subclass GtkApp

See merge request World/podcasts!113
2019-09-02 21:35:43 +00:00
Jordan Petridis
d8090a8172
Upgrade some cargo deps 2019-09-02 23:55:56 +03:00
Zander Brown
f47413686c
Use a custom GtkApplication instead of GtkApplication direct 2019-09-02 23:55:56 +03:00
Zander Brown
c8a194cf32
Use gtk-rs 0.7.0 2019-09-02 23:55:56 +03:00
Jordan Petridis
f07ac1f322 Merge branch 'url-creds' into 'master'
Parse url login info into basic auth header

Closes #124

See merge request World/podcasts!120
2019-09-02 18:49:42 +00:00
Peter Rice
02561b614f Parse url login info into basic auth header 2019-09-01 08:11:23 -04:00
Anders Jonsson
6ca2d02c69 Update Swedish translation 2019-08-22 15:54:41 +00:00
Jordan Petridis
300e103fed Merge branch 'cargo-update' into 'master'
build: Update Cargo.lock

Closes #147

See merge request World/podcasts!118
2019-08-05 00:34:13 +00:00
Christopher Davis
a77c0e5f32 build: Update Cargo.lock
Fixes https://gitlab.gnome.org/World/podcasts/issues/147
2019-08-04 04:04:15 -07:00
Jordan Petridis
cf644d508d Merge branch '145-contributing-merge-request-process' into 'master'
Resolve "CONTRIBUTING - Merge Request Process"

Closes #145

See merge request World/podcasts!117
2019-07-29 15:49:46 +00:00
Zander
452be8a22f Switch make for meson/ninja 2019-07-27 20:00:11 +00:00
Jordan Petridis
1e816f65a5 Merge branch 'alatiera/ci-3-32' into 'master'
Flatpak: use the 3.32 runtime version

See merge request World/podcasts!114
2019-07-16 08:05:17 +00:00
Jordan Petridis
d8496fe4c4
Flatpak: use the 3.32 runtime version
Until somebody find time to fix the CI image to work
with the 19.08 fd.o base.
2019-07-16 10:34:23 +03:00
Kukuh Syafaat
ca10956014 Update Indonesian translation 2019-07-11 08:22:04 +00:00
Goran Vidović
3984b84b6c Update Croatian translation 2019-06-22 20:30:46 +00:00
Daniel Mustieles
493114e825 Update Spanish translation 2019-06-19 10:34:04 +00:00
Yuri Gomes
7856b6fd27 Update Brazilian Portuguese translation 2019-06-13 18:42:37 +00:00
Jordan Petridis
957b47680d Merge branch 'empty-url' into 'master'
headerbar: Don't show error when add input is empty

See merge request World/podcasts!108
2019-06-09 04:18:48 +00:00
FeuRenard
86f6a944ff
headerbar: Don't show error when add input is empty
When you add a feed url and clear the input after entering some
characters, then the error label is shown. The empty url check in the
code is broken, because it is performed on a version of the url which
is not the original input and instead a version modified in the code.

I store in a variable whether the original input url is empty.

part of #45
2019-06-09 06:17:22 +03:00
Jordan Petridis
2631173a0d Merge branch 'red-border' into 'master'
headerbar: Add ERROR style to Add entry

See merge request World/podcasts!109
2019-06-09 03:12:33 +00:00
FeuRenard
0dc1f810d2
headerbar: Add ERROR style to Add entry
When you enter an invalid or duplicate URL an error message is shown.
But GTK's error style is not applied to the entry.

This commit applies GTK's error style to the URL entry when appropriate.

part of #45
2019-06-09 05:48:02 +03:00
Jordan Petridis
0b8d19fbbe Merge branch 'replace-label' into 'master'
headerbar: Replace Add error label with icon

See merge request World/podcasts!110
2019-06-09 02:38:40 +00:00
FeuRenard
51bbe4193b
headerbar: Replace Add error label with icon
Validation errors of an entry should be displayed by an icon with a
tooltip explaining the reason.

For the situation when you add a podcast URL I remove the existing
error label and show the former label text as tooltip of an error icon
in the entry.
2019-06-09 05:29:53 +03:00
Jordan Petridis
d5945f6ac6 Merge branch 'patreon_rss_fix' into 'master'
p-data/utils/url_cleaner: Keep query pairs in URLs

Closes #139

See merge request World/podcasts!111
2019-06-09 01:54:56 +00:00
Liban Hannan
539f8824d1 p-data/utils/url_cleaner: Keep query pairs in URLs
Removing query pairs prevents some podcasts from downloading. Patreon
private feeds (perhaps others) use tokens in query pairs to
authenticate downloads.
2019-06-09 01:27:10 +00:00
Jordan Petridis
539efc3f7b Merge branch 'code-of-conduct' into 'master'
Add Code Of Conduct

See merge request World/podcasts!112
2019-06-07 23:37:16 +00:00
Christopher Davis
c0861ba796 Add Code Of Conduct
This adopts the Contributor Covenant as the Code of Conduct
for all project spaces.
2019-06-07 16:03:45 -07:00
Balázs Úr
52d308e5ae Update Hungarian translation 2019-06-03 20:27:55 +00:00
Jiri Grönroos
57f3afae97 Update Finnish translation 2019-06-01 17:21:14 +00:00
Ask Hjorth Larsen
80ff75debc Update Danish translation 2019-05-28 01:34:31 +00:00
Piotr Drąg
d3a3bd2784 Update Polish translation 2019-05-19 10:38:13 +00:00
Jordan Petridis
2dcccb804e Merge branch 'piotrdrag/unicode-typography' into 'master'
Use a Unicode apostrophe in a new translatable string

See merge request World/podcasts!107
2019-05-18 12:55:23 +00:00
Piotr Drąg
ed7ac04d64 Use a Unicode apostrophe in a new translatable string
See https://developer.gnome.org/hig/stable/typography.html
2019-05-18 13:29:37 +02:00
Rodrigo Lledó
baf4d2bde6 Update Spanish translation 2019-05-16 16:24:52 +00:00
Jordan Petridis
ff047c5823 Merge branch 'alatiera/checkmark' into 'master'
episode: add a checkmark symbol to further indicate played state

Closes #106 and #69

See merge request World/podcasts!106
2019-05-14 09:11:16 +00:00
Christopher Davis
6c701e0c41 build: Add missing source file
In the transition to tightening our meson integration this
file was left untracked by meson.
2019-05-14 08:28:23 +00:00
Jordan Petridis
28ea14f2e9 episode: add a checkmark symbol to further indicate played state
Using only a dim styleclass on the widget is too light and does
not work with the HighContrast theme.

Close #69 #106
2019-05-14 08:28:23 +00:00
Jordan Petridis
f00f9b104c Merge branch 'piotrdrag/potfiles-190511' into 'master'
Update POTFILES.in

See merge request World/podcasts!105
2019-05-13 21:45:42 +00:00
Piotr Drąg
78d91826b1 Update POTFILES.in 2019-05-11 13:00:18 +02:00
Jordan Petridis
a5be789745 Merge branch 'alatiera/nuke-preferences' into 'master'
app: remove preferences dialog

Closes #67

See merge request World/podcasts!104
2019-05-10 16:05:46 +00:00
Jordan Petridis
028e318bd3 app: remove preferences dialog
The dark theme option is broken with themes that don't ship a
dark variant.

The episode garbage collection doesn't seem useful being
configurable at all.

The gsettings are still there, this just removes the ui
dialog since nothing useful made it into it ever.

Also, less toggles the better.

http://www.islinuxaboutchoice.com/
2019-05-10 17:08:35 +02:00
Jordan Petridis
b2b0b0f2c8
update dependencies 2019-05-10 15:57:55 +02:00
Jordan Petridis
42e73cb7e9
use mpris-player from crates.io 2019-05-10 15:56:28 +02:00
Jordan Petridis
89c3733ce8 Merge branch 'fix-#114' into 'master'
utils: use generic image when a show has no cover

See merge request World/podcasts!103
2019-05-10 13:40:59 +00:00
ZephOne
4be473dcea utils: use generic image when a show has no cover
When an episode from a show that has a cover is played. Switching to an
episode of a show that has no cover does not load the generic image in the
player. utils::set_image_from_path implementation does not deal with
DownloadError::NoImageLocation

utils::set_image_from_path deals with DownloadError::NoImageLocation,
this generic is set in this case.

https://gitlab.gnome.org/World/podcasts/issues/114
2019-05-10 14:01:47 +01:00
Jordan Petridis
97bdc32cce Merge branch 'master' into 'master'
Update CONTRIBUTING.md

See merge request World/podcasts!99
2019-04-07 15:55:20 +00:00
Julian Hofer
d2da46854e Update CONTRIBUTING.md 2019-04-07 13:17:39 +02:00
Jordan Petridis
150d3622e4 Merge branch 'patch-1' into 'master'
Fix  small errors in CONTRIBUTING.md

See merge request World/podcasts!98
2019-04-06 21:23:05 +00:00
Julian Hofer
990ab29200 Update CONTRIBUTING.md 2019-04-06 20:55:16 +00:00
Jordan Petridis
80d3bc84b8
cargo fmt 2019-04-06 23:54:17 +03:00
Jordan Petridis
b31c79431e
Fix the tests for Rust 2018 for real this time 2019-04-06 22:58:03 +03:00
Jordan Petridis
73587ff47b
Fix tests for Rust2018 2019-04-06 22:22:03 +03:00
Jordan Petridis
53e9db2f42
scripts/test.sh: export the rust binaries iniside flatpak environments
This allows the script to be able to be run directly from GNOME Builder's
build terminals.
2019-04-06 22:22:02 +03:00
Jordan Petridis
b290441956
scripts/cargo: fix debug build check 2019-04-06 21:33:42 +03:00
Jordan Petridis
9d0d20afbd
update deps 2019-03-30 18:13:39 +02:00
Jordan Petridis
4a7d3d5fc2
Use 2018 edition for the crates 2019-03-30 17:49:29 +02:00
Jordan Petridis
32ecb05902
don't export private macro 2019-03-30 17:08:28 +02:00
Jordan Petridis
644ca7d0d0
widgets/episode: replace deprecated method 2019-03-30 16:59:15 +02:00
Jordan Petridis
7dc1b25ee7
don't error on warnings
Nice and all when you keep up with development,
but can cause random errors by the addition of new
errors to the compiler.
2019-03-30 16:34:55 +02:00
Goran Vidović
3f28b9abc4 Update Croatian translation 2019-03-27 15:20:05 +00:00
Jordan Petridis
cf36d91da4 Merge branch 'patch-1' into 'master'
Fix typo in README.md

See merge request World/podcasts!97
2019-03-25 09:23:30 +00:00
Torkel Rogstad
8c1465d3a2 Fix typo in README.md 2019-03-25 08:52:22 +00:00
Jordan Petridis
bed7a4d6be
CI: fix the app_id that I forgot in the last commit 2019-03-20 23:54:40 +02:00
Jordan Petridis
23a91cca16
CI: fix the flatpak manifest path 2019-03-20 23:23:00 +02:00
Jordan Petridis
82c99e2cfa
meson: remove outdated fixme 2019-03-20 23:17:34 +02:00
Jordan Petridis
7d745179d4
app: Use a dot to separate the .Devel suffix in the app-id 2019-03-20 23:15:20 +02:00
Jordan Petridis
78283e51f6
app: remove .present() workaround
This should no longer be needed now.
https://gitlab.gnome.org/GNOME/gtk/issues/1754
2019-03-20 23:06:44 +02:00
Jordan Petridis
5a9ff8e331 Merge branch 'piotrdrag/update-potfiles-190307' into 'master'
Update POTFILES.in

See merge request World/podcasts!95
2019-03-16 12:42:21 +00:00
Piotr Drąg
814ddaa532 Update POTFILES.in 2019-03-16 12:29:47 +00:00
Bruce Cowan
b927b5fac2 Add British English translation 2019-03-16 11:47:15 +00:00
Anders Jonsson
e9cf140177 Update Swedish translation 2019-03-13 22:01:05 +00:00
Jordan Petridis
9ee9de7911
Flatpak: use ☢ emoji instead of the Nightly prefix 2019-03-10 21:07:29 +02:00
Nathan Follens
53844aa0ff Add Dutch translation 2019-03-10 12:54:18 +00:00
Jordan Petridis
f527f743fc
build: Require libhandy 0.0.9
Else libhandy breaks tranlations
2019-03-10 13:18:36 +01:00
Rafael Fontenelle
5801736955 Update Brazilian Portuguese translation 2019-03-10 01:32:30 +00:00
Tim Sabsch
11afc4c37d Update German translation 2019-03-09 18:58:46 +00:00
Jordan Petridis
5f1427cabd Merge branch 'wip/christopherdavis/tighten-meson-integration' into 'master'
build: Tighten integration between cargo and meson

Closes #55

See merge request World/podcasts!94
2019-03-07 05:16:03 +00:00
Jordan Petridis
27008e0f18
Flatpak: add builddir: true to the manifests
GNOME Builder carries a reimplementation of flatpak-builder.
flatpak-builder assumes that builddir is true for meson, but
Builder does not since it also supports out-of-tre builds.

This helps indicated to Builder that builddir can indeed be used.
2019-03-07 07:04:24 +02:00
Christopher Davis
8898fd6e2f
build: use build_by_default in cargo-build target
build_always_stale is deprecated and build_by_default can be
used now that we don't have build.rs.

Requires that we list our sources.
2019-03-07 07:04:24 +02:00
Christopher Davis
96d4cd50d4
CI: create blank versions of configured files before lint
Required now that we have dynamically generated
sources.
2019-03-07 07:04:24 +02:00
Christopher Davis
7a77f31aa3
build: Place target in meson build dir
Instead of putting target/ and target_test/ in the source
directory, we can tell our cargo script to put both target
and our cargo-home in meson's build directory.

In addition, makes tests and builds use the same target
directory, significantly reducing the time it takes to run tests.
2019-03-07 07:04:23 +02:00
Christopher Davis
78f29e726e
build: use add_dist_script for vendoring
Our workaround for getting meson and cargo working together
included a separate 'release' target that replaced
'ninja dist' so that we could vendor dependencies.

Now we use meson's add_dist_script to vendor the
dependencies as part of 'ninja dist', so we no longer need
the 'release' target.
2019-03-07 07:04:23 +02:00
Christopher Davis
92e2006782
build: hook up tests with meson
Adds our cargo test to meson's testing system so that
`ninja test` runs it in addition to our resource validation tests.
2019-03-07 07:04:23 +02:00
Christopher Davis
5b2edc73ec build: Build resources with meson instead of build.rs
Allows us to get rid of build.rs, which was only used to
compile resources. static_resource.rs is now created by
meson, and the meson path is used for include_bytes!.

Closes https://gitlab.gnome.org/World/podcasts/issues/55
2019-03-07 04:35:31 +00:00
Christopher Davis
395e31ff85 build: Use config.rs instead of env! macro
Previously we were using the env! macro to determine
build-time variables like version, app ID, and locale dir.
Instead of relying on env vars, we can create a configuration
file with meson and import it.
2019-03-07 04:35:31 +00:00
Sabri Ünal
dd0d828794 Update Turkish translation 2019-03-06 10:46:39 +00:00
Jordan Petridis
15bb1a2335 Merge branch 'escape-opml-export' into 'master'
p-data: Escape titles in OPML exports

See merge request World/podcasts!93
2019-03-04 04:26:38 +00:00
Christopher Davis
0a7b7880da
opml: escape characters when exporting
OPML is XML after all and parsers yell at us when we don't
escape &amps.
2019-03-04 05:54:01 +02:00
Jordan Petridis
277f324cf0 cargo fmt 2019-03-03 17:47:11 +02:00
Jordan Petridis
e496d5bf36 cargo: build debug symbols for release builds as well 2019-03-03 17:46:33 +02:00
Jordan Petridis
0ed6c8979e Upgrade crossbeam-channel 2019-03-03 17:45:43 +02:00
Jordan Petridis
075dd1adeb cargo: change email metadata of the crates 2019-03-03 04:59:52 +02:00
Jordan Petridis
027faf1949 Change formatting of podcasts-gtk/Cargo.toml 2019-03-03 04:58:40 +02:00
Jordan Petridis
54e049874c html2text: Use upstream git repo 2019-03-03 04:54:42 +02:00
Jordan Petridis
c4c6ba9ea4
Update dependencies 2019-03-03 04:46:19 +02:00
Jordan Petridis
a77bf0b8fb
Update gtk-rs family of crates 2019-03-03 04:46:19 +02:00
Jordan Petridis
b9bad14df6
build: Update libhandy to 0.0.8 at least
Earlier versions of libhandy broke translations for all apps.
sadly we can't require 0.0.9 yet as it hasn't been released yet.

https://mail.gnome.org/archives/desktop-devel-list/2019-March/msg00000.html
2019-03-03 04:10:47 +02:00
Kukuh Syafaat
b1058933b8 Update Indonesian translation 2019-03-02 12:18:56 +00:00
Jiri Grönroos
32c7f6b29e Update Finnish translation 2019-03-02 08:51:28 +00:00
Milo Casagrande
137705450b Update Italian translation 2019-02-28 08:25:39 +00:00
Ask Hjorth Larsen
4dc6034de8 Add Danish translation 2019-02-24 17:46:33 +00:00
Daniel Mustieles
7fa18fe38f Update Spanish translation 2019-02-19 15:05:09 +00:00
Jordan Petridis
7e34347ed7 Merge branch 'mpris-fix' into 'master'
add missing mpris callbacks / fix #115

Closes #115

See merge request World/podcasts!92
2019-02-05 21:52:55 +00:00
Felix Häcker
68fa547b06 Implement MPRIS pause and play methods.
Till now we were only using the play_pause method and
was enough for most of the usecases, but looks like
some mpris clients only use the individual methods.

https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:PlayPause

Close #115
2019-02-05 20:47:56 +00:00
Balázs Úr
a113ed049d Update Hungarian translation 2019-02-05 20:45:43 +00:00
Piotr Drąg
2f8a6a91f8 Update Polish translation 2019-02-02 15:31:37 +00:00
Jordan Petridis
f75ed257f2 Merge branch 'piotrdrag/unicode-typography' into 'master'
Use Unicode ellipsis in a user-visible string

See merge request World/podcasts!91
2019-02-02 15:17:22 +00:00
Piotr Drąg
06091d1af4 Use Unicode ellipsis in a user-visible string
See https://developer.gnome.org/hig/stable/typography.html
2019-02-02 12:14:29 +01:00
Daniel Mustieles
fb4e550122 Update Spanish translation 2019-01-29 15:28:41 +00:00
Balázs Úr
a74301f479 Update Hungarian translation 2019-01-28 22:42:49 +00:00
Rafael Fontenelle
c49f417d00 Update Brazilian Portuguese translation 2019-01-28 21:22:53 +00:00
Jordan Petridis
a2bcd8aa30 Merge branch 'opml-export' into 'master'
Add support for exporting to opml format

See merge request World/podcasts!77
2019-01-27 04:48:26 +00:00
Christopher Davis
6c34686d8d
p-data: Implment support for exporting the shows to an OPML file
Close #41
2019-01-27 06:06:46 +02:00
Jordan Petridis
86ec6f43cb
scripts:test.sh: fail upon exit code, and print stuff 2019-01-27 06:01:35 +02:00
Jordan Petridis
e9c7a3b99e
Flatpak: Add a nightly manifest 2019-01-27 01:44:06 +02:00
Jordan Petridis
f0ac63cd96
CI: add color to the test output 2019-01-21 03:13:51 +02:00
Jordan Petridis
b1e8663ba9
cargo update 2019-01-21 03:13:51 +02:00
Jordan Petridis
2cb6bf2b98
p-gtk: Update the libhandy bindings 2019-01-21 03:13:51 +02:00
Jordan Petridis
b35c63d1c8
Flatpak: Update libhandy to 0.0.7 and pin the commit
Lets avoid rolling git repos breaking the CI
2019-01-21 03:13:50 +02:00
Jordan Petridis
96b929b313
Flatpak: move USE_PLAYBIN3 env var to a finish arg 2019-01-21 03:13:50 +02:00
Jiri Grönroos
d8cddfafa0 Update Finnish translation 2019-01-16 18:04:16 +00:00
Jordan Petridis
de7d12d2c8 Merge branch 'patch-2' into 'master'
appdata: Improve the appdata file per the specs

See merge request World/podcasts!90
2019-01-10 06:18:19 +00:00
Bilal Elmoussaoui
bc8d521853 appdata: Improve the appdata file per the specs
freedesktop specs:https://freedesktop.org/software/appstream/docs/chap-Quickstart.html
kudos documentation: https://gitlab.gnome.org/GNOME/gnome-software/blob/master/doc/kudos.md
2019-01-09 19:21:29 +00:00
Jordan Petridis
a6c2666c82 Merge branch 'patch-1' into 'master'
Meson: add tests to validate desktop & appdata files

See merge request World/podcasts!89
2019-01-09 19:00:56 +00:00
Bilal Elmoussaoui
32424e7938 Meson: add tests to validate desktop & appdata files 2019-01-09 18:24:11 +00:00
Jordan Petridis
ca4a3d64eb
meson: remove dependency on gstreamer-bad-video-1.0
This has been moved to gst-base now.
b9e15fddb1
2019-01-05 15:41:46 +02:00
Jordan Petridis
2257688c65
Use rustfmt from the stable rustup channel 2019-01-05 15:11:27 +02:00
Jordan Petridis
ea9ddc58c0
scripts/test.sh: Minor tweaks 2019-01-05 15:11:27 +02:00
Milo Casagrande
c47d375a58 Update Italian translation 2018-12-11 16:49:14 +00:00
Jordan Petridis
36f97a5300
Flatpak: minor cleanup 2018-12-09 20:40:41 +02:00
Jordan Petridis
4adc3fadaa
Flatpak: Set an env var so gstreamer uses playbin3 2018-12-09 20:39:17 +02:00
Jordan Petridis
d9c64e7f87
CI: move the rustfmt check before the build/test 2018-12-09 04:02:29 +02:00
Jordan Petridis
9f8ae75691
Remove depricated lints 2018-12-09 03:53:17 +02:00
Jordan Petridis
7e0b88ddbd Merge branch 'remove-custom-devel-class' into 'master'
style.css: Remove custom .devel style class

See merge request World/podcasts!87
2018-12-08 23:00:14 +00:00
Christopher Davis
ed62f2b1f2 style.css: Remove custom .devel style class
GTK includes it's own .devel style class, so we don't need to
override by creating our own.
2018-12-06 00:30:00 -05:00
Jordan Petridis
4e89cdaca1 Merge branch 'alatiera/cargo-check' into 'master'
Boring build system stuff

Closes #109

See merge request World/podcasts!86
2018-12-02 00:40:49 +00:00
Jordan Petridis
b72ba8c66a
podcasts-gtk: make it possible to compile with just cargo
This is not supported at all, and it still won't run with cargo
run, thankfully, you should use meson instead...

The only purpose of this commit is to make it possible for
cargo check, and by extension rls, to function and work
properly.

Part of #110
2018-12-02 02:10:09 +02:00
Jordan Petridis
fc9de568bd
meson: remove dead code 2018-12-02 02:03:34 +02:00
Jordan Petridis
fe5a542e08
meson: Declare dependencies
Close #109
2018-12-02 02:03:33 +02:00
Jordan Petridis
5d86693d98 Merge branch 'fix/error-format' into 'master'
Remap rustc output to show errors in GNOME Builder sidebar

See merge request World/podcasts!81
2018-12-01 12:18:41 +00:00
Ricardo Silva Veloso
4caefdb3fd
Remap rustc output to show errors in GNOME Builder sidebar 2018-12-01 13:36:26 +02:00
Jordan Petridis
2f843a4d40 Merge branch 'patch-1' into 'master'
Support vendored builds / distribution packaging

See merge request World/podcasts!85
2018-11-30 11:25:59 +00:00
Michael Aaron Murphy
0175609f02 Support vendored builds / distribution packaging 2018-11-30 11:25:59 +00:00
Jordan Petridis
f9f0dad203
cargo fmt 2018-11-29 14:36:06 +02:00
Jordan Petridis
fdb064ffc8
Update dependancies 2018-11-29 11:57:27 +02:00
Jordan Petridis
e98231c327 Merge branch 'cleanups' into 'master'
Cleanups

See merge request World/podcasts!84
2018-11-18 13:27:59 +00:00
Jordan Petridis
0888da2197 Upgrade dependencies 2018-11-18 12:59:02 +00:00
Jordan Petridis
2d231ad989 Update dependancies 2018-11-18 12:59:01 +00:00
Jordan Petridis
19e0b7e565 Further preparations for Rusto 2018 edition 2018-11-18 12:59:01 +00:00
Jordan Petridis
fd4128c364 Prepare for Rust 2018 edition 2018-11-18 12:59:01 +00:00
Jordan Petridis
98f105fda0 Player: Use a wrapper struct to write methods on &self
Previously, methods that required the Player to be ref counted,
were using static methods with s: &Rc<Self> as the first argument.

Now the wrapper type auto-derefs to the inner struct and you can
declare methods on just &self.
2018-11-18 12:59:01 +00:00
Kukuh Syafaat
201f2e23c7 Update Indonesian translation 2018-11-15 16:42:30 +00:00
Jordan Petridis
32b257ec30
cargo fmt 2018-11-13 15:29:20 +02:00
Jordan Petridis
53bceb89cd
Merge branch 'Ophirr33/podcasts-master'
See !82 for more.
2018-11-07 01:34:06 +02:00
Jordan Petridis
04770a1e8f
Merge branch 'ZanderBrown/hammond-wip/zbrown/jump-to-start'
See !83 for more.
2018-11-07 00:49:47 +02:00
Zander Brown
fbda4c76f0
player.rs: Improve the fast-forward handling
Previously if you hit fast-forward but the offset remaining was
let that the amount you wanted to seek, it would do nothing.
Now it resets the stream and seekbar to the start.

Eventually this will just move on to the next episode in the
Queue once that's implemented.
2018-11-07 00:46:40 +02:00
Zander Brown
85387a0a9b
Fix for https://gitlab.gnome.org/World/podcasts/issues/105
Wow that was a quick one
2018-11-05 17:55:46 +00:00
Jordan Petridis
5ac4f6dcf9
Mpris: Actually raise the window
This fixes a bug where can_raise was never registered on dbus,
but it also works around a wayland issue with the .present()
method.

https://gitlab.gnome.org/GNOME/gtk/issues/624
2018-11-04 21:05:09 +02:00
Jordan Petridis
6671f8c6fe
podcasts-gtk: Add per file license annotations 2018-11-04 19:35:35 +02:00
Jordan Petridis
5b77bb4649
podcasts-downloader: Add per file license annotations 2018-11-04 19:35:35 +02:00
Jordan Petridis
8f6329d71d
podcasts-data: Add per file license annotations 2018-11-04 19:35:35 +02:00
Ty Coghlan
5d71ac584c gnome-podcasts: DRY out From impls in errors
From impls for errors generally just take some error type and map
it into a variant of some podcast error enum. This removes the duplicate
impls by using a pattern macro to make the impls from the type of the
enum, the given error type, and the desired enum variant.
2018-11-03 01:13:35 -04:00
Daniel Mustieles
534c627300 Update Spanish translation 2018-11-02 12:26:05 +00:00
Jordan Petridis
1a1e5ecd3f
Update App icon. Close #102 2018-10-30 20:34:47 +02:00
Alexandre Franke
3e555c64d9 Add French translation 2018-10-29 12:54:45 +00:00
Dušan Kazik
8d6b5f7105 Update Slovak translation 2018-10-28 21:00:26 +00:00
Dušan Kazik
4692be663e Add Slovak translation 2018-10-28 15:31:17 +00:00
gogo
5ae0fb1b0e Update Croatian translation 2018-10-24 19:46:48 +00:00
Jordan Petridis
990d830f24
cargo fmt 2018-10-23 13:23:01 +03:00
Jordan Petridis
c6aa90db3e Merge branch 'master' into 'master'
fix typo

See merge request World/podcasts!79
2018-10-23 09:56:48 +00:00
lightning1141
c1eed45194
fix typo 2018-10-23 10:52:33 +08:00
Anders Jonsson
31ee668311 Update Swedish translation 2018-10-16 19:53:58 +00:00
Jordan Petridis
9f60121609
Flatpak: Cleanup libhandy headers 2018-10-09 12:58:40 +03:00
Piotr Drąg
a61d04f445 Update Polish translation 2018-10-07 14:57:18 +00:00
Marek Černocký
8ba8ada253 Update Czech translation 2018-10-07 14:48:24 +00:00
Jordan Petridis
885b796f85
New release 0.4.6 2018-10-07 10:18:15 +03:00
Jordan Petridis
fa47806c93
Update Changelog 2018-10-07 10:18:14 +03:00
Emin Tufan Çetin
5997666bad Update Turkish translation 2018-10-06 15:53:09 +00:00
Jordan Petridis
40186ce155
Update dependancies 2018-10-06 11:29:52 +03:00
Jordan Petridis
9cfdb35224
AboutDialog: Update contributors list 2018-10-06 11:09:07 +03:00
Jordan Petridis
64860c4624
Update screenshots 2018-10-06 10:32:46 +03:00
Jordan Petridis
32bd2a89a3
Stacks: Check if there episodes insteads of shows
If you added a Feed where a Show exists but it had no episodes
entries, the stack would end up in a populated state, but the
HomeView would be blank without widgets.

This changes it so the stack state depends upon the episodes
table being populated instead of the show. The downside
is that if your only feed is one without episodes you can
no longer navigate and interact with it.
2018-10-06 10:22:24 +03:00
Rafael Fontenelle
1e6eca307b Update Brazilian Portuguese translation 2018-10-05 14:19:31 +00:00
Jordan Petridis
fc23fcd7c1 Merge branch 'patch-1' into 'master'
Fix typo

See merge request World/podcasts!78
2018-10-05 11:47:38 +00:00
Alexandre Franke
ab52825c71 Fix typo 2018-10-05 10:04:44 +00:00
Jordan Petridis
28def30510 Update CHANGELOG.md 2018-10-05 09:25:56 +00:00
Florian Heiser
8f4d017180 Update German translation 2018-10-05 04:41:29 +00:00
Jordan Petridis
df302ad517
Pipeline: Do not terminate the stream upon errors
Stream::for_each terminated the stream upon the first error. This
was causing feeds to not update if any one returned a non-200ish
result. To work around this, we create a succesfull result for
every entry regardless at the end.

While we are at it, aslo switch from FuturesOrdered stream
to FuturesUnordered. There is no reason to use Ordered, this was
a typo initially.
2018-10-04 18:48:11 +03:00
Jordan Petridis
357d99ac7c
Remove temporary file 2018-10-04 17:18:07 +03:00
Jordan Petridis
7e3fecc44a
Source: Refactor the clear_etags method api 2018-10-04 16:46:04 +03:00
Jordan Petridis
e0b3dd9795
Mpris: Implement the raise method 2018-10-04 16:46:04 +03:00
Jordan Petridis
6a52a2bc46 Merge branch 'alatiera/player-timeout' into 'master'
Player: Tweak the smart rewind behavior

See merge request World/podcasts!76
2018-10-04 07:55:21 +00:00
Jordan Petridis
cd2b087006 Player: Check the episode id before triggering a smart rewind 2018-10-04 05:27:11 +00:00
Jordan Petridis
ef2940142c PlayerInfo: Store the id of the current playing episode 2018-10-04 05:27:11 +00:00
Jordan Petridis
e13b8b8827 Player: Tweak the smart rewind behavior
Check if time interval passed since the last pause, and only
rewind if the delta to indicates that the user had
switched their focus.

In other words, avoid rewinding if the track was just paused and
resumed.
2018-10-04 05:27:11 +00:00
Jordan Petridis
43fce2e89a
CI: Use generic bundle name
When run an environment CI_COMMIT_SHA is not resolved and results
into a 404 url in the review apps
2018-10-04 08:25:50 +03:00
Jordan Petridis
7856f0d602 Merge branch 'mpris' into 'master'
Implement MPRIS, Close #68

See merge request World/podcasts!74
2018-10-04 03:26:03 +00:00
Jordan Petridis
bcc6ab50e2 PlayerInfo: Minor refactor of the mpris cover 2018-10-04 02:19:56 +00:00
Jordan Petridis
23aa8c05ab Player: Do not duplicate the mpris instance 2018-10-04 02:19:56 +00:00
Jordan Petridis
986d898217 Player: Remove an unwrap 2018-10-04 02:19:56 +00:00
Jordan Petridis
654c0e5e56 Player: Remove Initialization test
When constucting Player in the Sandbox, it tries to use X-11
for dbus-autolaunch which is disabled in the flatpak environment.

It fails with the following error:
D-Bus error: Using X11 for dbus-daemon autolaunch was disabled at compile time, set your DBUS_SESSION_BUS_ADDRESS instead (org.freedesktop.DBus.Error.NotSupported)
2018-10-04 02:19:56 +00:00
Felix Häcker
fed0edbf16 Player: use crates.io package of mpris 2018-10-04 02:19:56 +00:00
Felix Häcker
ede91da6f8 cargo fmt 2018-10-04 02:19:56 +00:00
Felix Häcker
1f18d4291f Player: implement fast forward / rewind for mpris 2018-10-04 02:19:56 +00:00
Felix Häcker
d066e8939d Player: implement mpris play/pause callbacks 2018-10-04 02:19:56 +00:00
Felix Häcker
e4c3435d34 Player: add basic mpris support 2018-10-04 02:19:56 +00:00
Jordan Petridis
fc80b180bc
CI: Allow rusftm check to fail for now
Stuff broke with rustfmt nightly 0.99.5 and I haven't found time
to look into it yet.
2018-10-04 05:17:21 +03:00
Jordan Petridis
0d9dca99e9
Meson: capture the output of the custom target
Previously meson would withhold the stdout output, and only
print it once the job was finished. This was a bit problematic
with the current way we wire cargo into meson, cause you have
no output of whats going on till then causing a degraded UX.

With this change output is printed directly to stdout and
behaves as expected.

https://mesonbuild.com/Reference-manual.html#custom_target
2018-10-04 04:58:57 +03:00
Kukuh Syafaat
2ff921cc1a Update Indonesian translation 2018-10-02 13:34:32 +00:00
Piotr Drąg
e0e66fa6af Update Polish translation 2018-09-30 17:17:46 +00:00
Marek Černocký
110e29ec5a Update Czech translation 2018-09-29 19:31:22 +00:00
Jordan Petridis
a2b6d622de
Update dependancies 2018-09-29 13:58:45 +03:00
Jordan Petridis
0887789f5e
Pipeline: Complete the move to Tokio Runtime 2018-09-29 13:35:45 +03:00
Jordan Petridis
3c4574f2ec
scripts/test.sh: Don't keep build-dirs 2018-09-29 12:07:21 +03:00
Jordan Petridis
62029f6164
Pipeline: Minor refactor
Use the proper Stream API to return a future to run on the
executor. Previously I was using a workaround to convert the
Stream into a future and run it to completion in the Executor,
since I was not aware of a better API.
2018-09-29 12:03:00 +03:00
Jordan Petridis
ba986847d6
Merge branch 'wip/hyper-12.X-update'
See merge request World/podcasts!75
2018-09-29 11:42:53 +03:00
Jordan Petridis
fded78ce6f
Flatpak: Switch to the master runtime again 2018-09-29 11:18:39 +03:00
Jordan Petridis
8d44649a1e
Update dependancies 2018-09-29 09:54:06 +03:00
Jordan Petridis
f4d0c51dc2
podcasts-downloader: Upgrade reqwests to 0.9 2018-09-29 09:54:06 +03:00
Jordan Petridis
ba60db9977
podcasts-gtk: Upgrade reqwests to 0.9 2018-09-29 09:54:06 +03:00
Jordan Petridis
0a5a7a684d
podcasts-downloader: Remove direct dependancy on hyper 2018-09-29 09:54:06 +03:00
Christopher Davis
7181c46ed5
podcasts-data: Upgrade hyper to 12.X
This also allows us to bump hyper_tls and native_tls, bringing
support for openssl 1.1.
2018-09-29 09:54:06 +03:00
Jordan Petridis
afa7e69347 Update CHANGELOG.md 2018-09-28 23:56:51 +00:00
Jordan Petridis
5050dda4d2
Hamburger: Update to reflect the latest HIG changes
Preferences are now grouped with the application actions.
The About label was renamed to "About Podcasts".

https://mail.gnome.org/archives/desktop-devel-list/2018-September/msg00015.html
2018-09-29 02:43:19 +03:00
Florian Heiser
9ea1e16ac8 Update German translation 2018-09-27 15:32:33 +00:00
Rodrigo Lledó
922f44f605 Update Spanish translation 2018-09-24 10:17:44 +00:00
Adolfo Jayme Barrientos
6ea3fc918b Add Catalan translation 2018-09-24 07:52:18 +00:00
Kukuh Syafaat
0c201533f0 Update Indonesian translation 2018-09-22 08:20:38 +00:00
Jordan Petridis
b1e99b96c4
CI: Switch the flatpak image 2018-09-22 04:53:08 +03:00
Jordan Petridis
de1c8485ae
Fix rustc warnings
New warnings were introduced with rustc 1.29.0

podcasts-data/src/lib.rs: this one can be removed once diesel is
upgraded.
https://github.com/diesel-rs/diesel/issues/1785#issuecomment-422577018

podcasts-gtk/src/i18n.rs: This is just a deprication warning
2018-09-22 04:47:24 +03:00
Jordan Petridis
565d1d0388
Build against the 3.28 runtime for now
Freedesktop 18.08.11 dropped openssl 1.0 which caused hyper to
no longer build. Reverting to the old runtime till hyper is
upgraded. See #84 for more.
2018-09-22 04:47:24 +03:00
gogo
a15fea1d65 Update Croatian translation 2018-09-19 12:24:57 +00:00
Jordan Petridis
da3cf6ca27
Flatpak: Pass configuration to libhandy
Do not build what we don't need.
2018-09-18 12:43:51 +03:00
Emin Tufan Çetin
d8dbbc6832 Update Turkish translation 2018-09-15 20:11:23 +00:00
Marek Černocký
3563a964ef Add Czech translation 2018-09-15 05:15:51 +00:00
Jordan Petridis
208f0c248d
Update rest of the dependancies 2018-09-13 15:47:40 +03:00
Jordan Petridis
c0e034726a
Upgrade gtk-rs and friends
Upgrade gtk-rs, and everything dependings on it like gst-rs and
libhandy bindigns
2018-09-13 15:47:40 +03:00
Rafael Fontenelle
d676a7071a Update Brazilian Portuguese translation 2018-09-11 00:12:22 +00:00
Rūdolfs Mazurs
a681b2c944 Add Latvian translation 2018-09-10 17:34:18 +00:00
Mario Blättermann
c53701d56b Update German translation 2018-09-10 07:50:51 +00:00
Anders Jonsson
a8c1f2eccc Update Swedish translation 2018-09-09 17:39:22 +00:00
Piotr Drąg
cf7ee44efc Update Polish translation 2018-09-09 17:00:14 +00:00
Jordan Petridis
2e4a9eaaeb
Update dependancies 2018-09-08 21:41:39 +03:00
Jordan Petridis
569a2b5694
Update CHANGELOG.md 2018-09-08 20:17:48 +03:00
Jordan Petridis
baa84773a5
EpisodeWidget: Change the "cancel" action icon 2018-09-08 20:17:47 +03:00
Jordan Petridis
a39e642b5a
EpisodeWidget: Change cacnel button to an icon
This also reworks the button_box to be just a GtkBox instead.
I couldn't get ButtonBox to behave the way I wanted.

Fixes #89
2018-09-08 20:17:47 +03:00
Jordan Petridis
e42cb49cbe
EpisodeWidget: Hide total_size if request fails
This moves the rest of the methods of Progress struct to the downloader
trait and cancels the Progress if the request does nto succed.

Close #90
2018-09-08 20:17:47 +03:00
Jordan Petridis
b40c12efbd
Pipeline: Avoid spamming stderr when not needed
This commit add a new DataError Variant for feeds that return 304.
Its expected behaviror and the current API of Source::into_feed
is kinda limiting the return type to make it easier to handle.

Up till now 304 was returning an Error to early return. Ideally
Source::into_feed will return a Multi variant Result Enum.

example:

enum FeedResult {
    Ok(Success(feed)),
    Ok(NotModified),
    Err(err),
}

Hopefully in a refactor in the near Future™

Till then we will just have to match and ignore
DataError::FeedNotModified.
2018-09-08 20:17:47 +03:00
Jordan Petridis
3c5ddad133
DataError: Improve the FeedRedirect variant 2018-09-08 20:17:47 +03:00
Jordan Petridis
1ca4cb40dd
Pipeline: clear the imports of fututres 2018-09-08 20:17:47 +03:00
Jordan Petridis
678b0b9db1
BaseView: Set minimum width to 360
While the HomeView and ShowView can't yet scale that low,
the ShowWidget could get to about 270p already which is not
desirable.

This commit sets the minimum width of all the Views to 360p,
which is our mobile target size.
2018-09-08 20:17:47 +03:00
Jordan Petridis
e633fa41ac
Pipeline: Minor formatting improvment 2018-09-08 20:17:47 +03:00
Jordan Petridis
dac303e33b
Pipeline: Use a custom tokio threadpool
This reverts commit e64883eecb
and 40dd2d6923

Seems like core.run() returns once its done even if there
are still tasks in the Runtime underneath. A way to solve that
would be to call the shutdown_on_idle method.

We need ownership of the threadpool in order to invoke
`shutdown_on_idle` method but core.runtime only returns a
referrence so we need to create our own threadpool.
2018-09-08 20:17:46 +03:00
Jordan Petridis
fd77d672c5
Update Changelog 2018-09-08 20:17:46 +03:00
Jordan Petridis
a991d9f512
DataError: Remove unused error variant
This was added due to Threadpool::spawn returning errors, but its
no longer used.
2018-09-08 20:17:46 +03:00
Jordan Petridis
674b3b54dc
Pipeline: reuse the prexisting runtime executor
Instead of creating our own threadpool, we should reuse the executor
of the tokio::runtime::Runtime that backs the tokio::reactor::Core.
2018-09-08 20:17:46 +03:00
Jordan Petridis
09948845ca
Pipeline: Remove use of clone! macro 2018-09-08 20:17:46 +03:00
Jordan Petridis
0b8a0695f7
Pipeline: Use tokio threadpool to index feeds 2018-09-08 20:17:46 +03:00
Jordan Petridis
a7c95d5718
Pipeline: Remove dependancy on rayon_futures
This requires a RUSTFLAG to be set before hand for rayon to build.
This brakes a lot of tools like rls and clippy by default and
require special configs for itnegration.

Additionally, rayon_futures is still 0.1 and not much work seem
to have gone into it. Ideally it should be replased with the tokio
runtime/threadpool.
2018-09-08 20:17:46 +03:00
Jordan Petridis
8a05597e52
Feed: Remove another unnecessary wrapper 2018-09-08 20:17:45 +03:00
Jordan Petridis
145d45f800
Feed: Remove unnecessary function wrapper 2018-09-08 20:17:45 +03:00
gogo
0476b67b2f Add Croatian translation 2018-09-06 18:29:14 +00:00
Balázs Meskó
2751a828e0 Add Hungarian translation 2018-09-05 22:09:46 +00:00
Jordan Petridis
a3f5dbfe07
CI: cache only the sources from flatpak-builder
Gitlab CI zips its cache target which causes it to strip
xattributes. This breaks ostree and the cache artifacts.

Cache only the sources instead.
2018-09-05 00:47:45 +03:00
Fabio Tomat
60e09c0dd7 Add Friulian translation 2018-09-04 18:18:27 +00:00
Milo Casagrande
a23297e56a Add Italian translation 2018-09-03 13:42:38 +00:00
Jordan Petridis
b05163632b
test.sh: build with devel profile as well 2018-09-03 11:17:03 +03:00
Daniel Mustieles
f59be31ded Update Spanish translation 2018-09-03 06:32:14 +00:00
Anders Jonsson
2e527250de Add Swedish translation 2018-09-02 23:35:41 +00:00
Piotr Drąg
91dd378f5d Update Polish translation 2018-09-01 17:49:54 +00:00
Jiri Grönroos
f5b3d033a3 Update Finnish translation 2018-09-01 16:59:07 +00:00
Rafael Fontenelle
db98e3b722 Add Brazilian Portuguese translation 2018-09-01 13:29:55 +00:00
Mario Blättermann
586cf16fdc Update German translation 2018-08-31 19:05:31 +00:00
Jordan Petridis
064d877205
Appdata: add release notes 2018-08-31 21:00:18 +03:00
Jordan Petridis
fbf8cc87c9 Version bump 2018-08-31 14:14:17 +00:00
Jordan Petridis
bebabf84a0 Update Changelog 2018-08-31 14:11:29 +00:00
Seong-ho Cho
36f169635a Add Korean translation 2018-08-31 00:56:44 +00:00
Jordan Petridis
3f509f44a1
Update dependancies. 2018-08-30 20:45:53 +03:00
Jordan Petridis
9bc8a8ac2b
Release: include meson_options.txt 2018-08-30 20:21:11 +03:00
Jordan Petridis
abfe98283b
EpisodeWidget: Allow size labels to elipsize 2018-08-30 20:21:10 +03:00
Kukuh Syafaat
ded0224f51 Add Indonesian translation 2018-08-30 10:43:20 +00:00
Fran Dieguez
0060a634d2 Add Galician translation 2018-08-30 08:45:45 +00:00
Jordan Petridis
d34005e04f Merge branch 'chng-readme' into 'master'
README.md: Change instructions for translators

See merge request World/podcasts!68
2018-08-29 08:43:48 +00:00
Rafael Fontenelle
c734bd48b5 README.md: Change instructions for translators
Change translation instructions to steer the translator to the translation platform D-L, to follow the proper workflow of the language teams.
2018-08-29 07:29:38 +00:00
Jordan Petridis
174c814541 Merge branch 'alatiera/cover_load_perf' into 'master'
Cover loading performance improvments

See merge request World/podcasts!67
2018-08-29 07:29:17 +00:00
Jordan Petridis
993b6e9d0a Utils: only queue a single cover download
Before we were inserting the id of the cover into the registry
from a rayon thread. But rayon will only execute N threads at the
same time and let the rest into a queue. This would casue mutliple
jobs being queued since the cover id was not inserted in the
registry until the downloading had started.

This fixes said behavior by having the main thread block and write
in the id in the registry.
2018-08-28 21:15:52 +00:00
Jordan Petridis
273c9f7b99 Utils: Change the priority of the cover caches
Since loadign a pixbuf from the pre-rendered cache is the most
common operation and it does not affect the behavior we can
first check that and then if the cover is midway downloading.

This avoids a mutex lock for the most common path.
2018-08-28 21:15:52 +00:00
Jordan Petridis
822deb2867 Utils: do not block the cover_dl registry
Accidently after f21398357b when a download would start,
it would lock the cover_dl_registry hashmap till it had finished.

Since the registry.read() happens on the main thread this would
cause the UI to block until the download was and the mutex guard
from the download thread dropped.
2018-08-28 21:15:52 +00:00
Merge Bot
132e2afce0 Merge branch 'wip/piotrdrag/update-potfiles-180828' into 'master'
Update POTFILES.in

See merge request World/podcasts!66
2018-08-28 20:54:09 +00:00
Piotr Drąg
2a888f0bce Update POTFILES.in 2018-08-28 19:44:34 +02:00
Jordan Petridis
f8202a7add Merge branch 'alatiera/meson-stuff' into 'master'
Parallel installation and meson cleanups

See merge request World/podcasts!64
2018-08-28 16:13:31 +00:00
Jordan Petridis
87e8d0b775
Fix the test-suite 2018-08-28 17:50:57 +03:00
Jordan Petridis
569c00ff5f
Allow for parallel development instance
This adds a configuration option in meson, if set it changes the
application ID allowing for stable and development version to be
run at the same time.
2018-08-28 17:22:13 +03:00
Jordan Petridis
15457e1db4
App/Build: Use env! macro to fetch the variable
env! is resolved at compile time which means we don't need to read
and set LOCALDI from build.rs
2018-08-28 17:22:13 +03:00
Jordan Petridis
0ae1eb9578
build: Translate desktop and appdata files 2018-08-28 17:22:13 +03:00
Jordan Petridis
aa1d0161d3
cargo fmt 2018-08-27 21:08:29 +03:00
Emin Tufan Çetin
a2d8b88337 Update Turkish translation 2018-08-27 08:43:08 +00:00
Jordan Petridis
a1b4306954
Merge remote-tracking branch 'zander/master'
See !65 for more.
2018-08-27 10:53:07 +03:00
Zander Brown
88e07031a6 Workaround for FileChooserNative oddness 2018-08-27 07:52:09 +00:00
Piotr Drąg
cb4daa1ba1 Update Polish translation 2018-08-26 16:31:44 +00:00
Jordan Petridis
46cfa79e89
Update .gitignore 2018-08-26 12:39:26 +03:00
Jordan Petridis
a480f47cea
cargo fmt 2018-08-26 12:38:36 +03:00
Jordan Petridis
4ef789e7b9
Meson: Fix build_always deprication warning
build_always has been replaced by build_always_stale and
build_by_default is assumed now.

https://mesonbuild.com/Reference-manual.html#custom_target
2018-08-26 12:38:36 +03:00
Mario Blättermann
05d6d8399d Update German translation 2018-08-25 21:27:45 +00:00
Jordan Petridis
8bc81d6b5e
Update pot files 2018-08-22 08:29:30 +03:00
Jordan Petridis
6c5cb8f07d
AboutDialog: Translate website label 2018-08-22 08:29:12 +03:00
Jordan Petridis
70e79e50d6
Change the website to point to the wiki page
Thanks a lot to @svito for creating the page!
2018-08-21 20:11:38 +03:00
Jordan Petridis
d2c6c6cc4d
Update pot files 2018-08-20 14:09:34 +03:00
Jordan Petridis
ba5e22bd21
README: Add flathub banner 2018-08-20 13:55:15 +03:00
Jordan Petridis
9d0bfdea44
README: Update build instructions 2018-08-20 13:41:19 +03:00
Jordan Petridis
92ae681517
Add gtk tests
Currently we only test the GtkBuilder files.
Also I can't find a way to get gtk to uninitialize and reinitialize
in a different thread.

Close #56
2018-08-20 13:14:41 +03:00
Jordan Petridis
3afa8c4441
Flatpak: avoid the git redirect 2018-08-20 05:27:31 +03:00
Emin Tufan Çetin
547fdef9c4 Update Turkish translation 2018-08-19 14:54:27 +00:00
Merge Bot
152c250300 Merge branch 'alatiera/content_states' into 'master'
content states

Closes #71

See merge request World/podcasts!63
2018-08-19 11:56:06 +00:00
Jordan Petridis
04161284a7
Headerbar: Make the switcher insensitive if empty
If there are no shows/episodes to display, there isn't any point
to being able to hit the switcher.
2018-08-19 14:31:27 +03:00
Jordan Petridis
14d4818867
App: Disable refresh action while in empty state
Close #71
2018-08-19 14:25:41 +03:00
Jordan Petridis
9f42e91088
Refactor content state with Application actions
Instead of each view/widget determening if its populated on its own,
make add Application Actions and apply the state globally.
2018-08-19 13:44:11 +03:00
Jordan Petridis
79ac3b9700
Update dependancies 2018-08-18 18:33:26 +03:00
Jordan Petridis
7a3178896b
Remove criterion
Haven't been able to use it effectivly with futures. Maybe will
revisit it again at a later time. For now it just adds extra
build time.
2018-08-18 18:02:28 +03:00
Jordan Petridis
ee95512321
Update .gitignore 2018-08-18 17:37:06 +03:00
Jordan Petridis
55519b1855
scripts/test.sh: avoid conlfict with vanila cargo
I am not sure why, but cargo does not like it if flatpak-builder
and gnome Builder try to use the same target/ directory, even
though the environment should be almost identical.

The only difference is that Builder uses flatpak build instead of
flatpak-builder as far as I can see.
2018-08-18 17:34:49 +03:00
Jordan Petridis
89b99614a0
Refactor the tests to use ? operator
As of ructc 1.27 #[test] can return errors. This improves a great
deal the ergonomics.
2018-08-18 17:02:31 +03:00
Jordan Petridis
7bbd9a1a4f
Update the test script 2018-08-18 16:25:43 +03:00
Tobias Bernard
0dfb48593e App icon: orange -> red 2018-08-17 12:49:44 +02:00
Jordan Petridis
3abe0803d6
Source: Remove reduntant save() call
Source::clear_etags does a save already, so it's not needed to call
it twice.
2018-08-17 09:57:49 +03:00
Merge Bot
1e0a919dc7 Merge branch 'alatiera/source-redirects-85' into 'master'
Source: Improve http redirections handling

Closes #85

See merge request World/podcasts!62
2018-08-17 04:33:12 +00:00
Jordan Petridis
489e8aa4b3
Source: Improve http redirections handling
We should follow all redirects, and update the Source uri for
301, 302 and 308 codes.

Closes #85
2018-08-17 07:09:12 +03:00
Merge Bot
5058f2f8d8 Merge branch 'alatiera/source-etags-71' into 'master'
Source: Only save Etag headers upon succesful requests

Closes #64

See merge request World/podcasts!61
2018-08-17 02:56:04 +00:00
Jordan Petridis
49aff9f22e
Source: Only save Etag headers upon succesful requests
Additionally clear the Etags if the returned code is not 200 or 304.
Just to be extra safe. This is not as clean as it should, as this is
a temporary workaround until the API is reworked.

Related to #64
2018-08-17 05:36:34 +03:00
Jordan Petridis
775d4accf7
Flatpak: switch to master branch of the runtime 2018-08-16 08:30:46 +03:00
Jordan Petridis
703a1baf8b
CI: Move repo export right before the bundle generation 2018-08-16 08:30:42 +03:00
Jordan Petridis
51fdad2ae2
CI: Use variables instread of hardcoding things 2018-08-16 08:30:38 +03:00
Jordan Petridis
86545e5f99
CI: Add git commit sha in the bundle name 2018-08-16 08:30:34 +03:00
Jordan Petridis
5a0413b3e4
CI: Remove --libdir=/app/libdir from meson config
This is not needed anymore I think.
2018-08-16 08:30:29 +03:00
Jordan Petridis
a0ff2b8ae4
CI: set CARGO_HOME before cargo test
This is set in the scripts/cargo.sh during building, but since we
invoke cargo test manually we need to also set it here.
2018-08-16 08:30:24 +03:00
Mario Blättermann
6b6c390cb8 Add German translation 2018-08-15 17:19:11 +00:00
Jordan Petridis
cd937c4844
utils: Refactor refresh_feed
Move channel creation inside the thread.
Drop the Result return type as its not needed anymore.
2018-08-14 15:24:25 +03:00
Jordan Petridis
cc1a5783fd
App: Do not update the db if its empty
If the source table is empty skipp the database refresh.
2018-08-14 15:19:31 +03:00
Jordan Petridis
5631caad36
Gitlab: Fix the bug issue template
Forgot to change this when it was copied from nautilus.
2018-08-14 14:23:35 +03:00
Jordan Petridis
d0144fb0d0
CI: Do not spin up review enviorment for tags 2018-08-14 14:11:16 +03:00
Jordan Petridis
5d1870c1cf
CI: Lower flatpak bundle lifespan 2018-08-14 14:03:06 +03:00
Jordan Petridis
fb87460bc7
CI: Clear unused stuff
No point on keeping them around since libhandy was added as a dep.
No distro packages libhandy currently, so only the flatpak will be
buildable for the foreseeable future.
2018-08-14 14:01:14 +03:00
Jordan Petridis
cb122cbc61
ShowWidget: Change description wrap mode 2018-08-14 13:59:42 +03:00
Jordan Petridis
efc4e299ac
CI: Add env viariable for the tests 2018-08-14 13:58:09 +03:00
Jordan Petridis
848caa275b
CI: re-enable the test-suite 2018-08-14 13:41:12 +03:00
Jordan Petridis
03754c56c6
Fix the test-suite 2018-08-14 13:40:37 +03:00
Jordan Petridis
471f6ff93b
Source: Remove ignore_etags option
This is never used anywhere else apart from the testsuite. Instead
of ignoring etags we should instead not save them if the feed does
not return 200 or 304. See #64.
2018-08-14 13:40:33 +03:00
Jordan Petridis
c53ad56a6d
Remove TODOs and FIXMEs
They are either no longer relevant or just forgotten
2018-08-14 13:40:28 +03:00
Tobias Bernard
646439d86a Merge branch 'alatiera/move-update-notif' into 'master'
Move the update indication into an In-app Notification

Closes #72

See merge request World/podcasts!60
2018-08-14 10:11:28 +00:00
Jordan Petridis
ae7f65e938
InAppNotif: Switch the timer to milliseconds
This allows for more responsive updates. The implementation still
sucks though. Ideally we would pass a receiver in the callback
and have an even lower timeout_add.
2018-08-14 07:58:29 +03:00
Jordan Petridis
b2d71a037c
Headerbar: Remove the update indicator 2018-08-14 07:52:41 +03:00
Jordan Petridis
019ec8972f
InAppNotif: Add a spinner 2018-08-14 07:41:58 +03:00
Jordan Petridis
e25e411ebe
App: Use the new updater notif
Initial wiring of the new InAppNotif update indicator. This still
misses a spinner, and its overall teribly implemented!
2018-08-14 07:28:10 +03:00
Jordan Petridis
911dcbac9f
InAppNotif: Pass revealer to the callback
Let the callback handle if/when the visibility of the notification
2018-08-14 06:22:02 +03:00
Jordan Petridis
25195c972c
InAppNotif: add a method to show/hide the close button
This will enable us to create persistant notifications.
2018-08-14 05:36:17 +03:00
Jordan Petridis
304c92f733
InAppNotification: Allow to set a custom timer
This allows for a custom timer to be set before the
callback will be run. Currently all the callbacks only
run once and then retunr glib::Continue(false) but this
would allow for setting a low timer and have a callback
that would determine if it needs to be run again, Continue(true),
in a relative responsive way.
2018-08-13 09:13:39 +03:00
Jordan Petridis
336b9a126e
InAppNotif: Fix ref cycles 2018-08-13 09:13:10 +03:00
Jordan Petridis
01efbf5c79
InAppNotif: Refactor to infer the undo state
If we use an Optional instead of passing empty closures, we
can infer if the Undo button needs to be shown.
2018-08-13 08:31:52 +03:00
Merge Bot, Bors Wannabe
866fa6a758 Merge branch 'fix-ref-cycles' into 'master'
Fix more refference cycles

See merge request World/podcasts!59
2018-08-13 04:28:49 +00:00
Jordan Petridis
b8bb5e6d82
ShowMenu: Fix a reference cycle 2018-08-13 06:48:39 +03:00
Jordan Petridis
cc4b3cce55
Player: Fix a refference cycle 2018-08-13 06:35:19 +03:00
Jordan Petridis
5699562133 App: Fix more refference cycles 2018-08-13 03:34:37 +00:00
Piotr Drąg
dae064d2bb Update Polish translation 2018-08-13 01:45:08 +00:00
Jordan Petridis
d54e15cd15
EmptyView: Fix a typo 2018-08-13 04:05:28 +03:00
Jordan Petridis
dbdf56d494
App: Do not placeholder strings as translatable
Also add license metadata, and update the targeted gtk3 version
2018-08-13 02:34:53 +03:00
Jordan Petridis
b07cd5515a
Refactor Empty states 2018-08-13 02:05:09 +03:00
Emin Tufan Çetin
73929f2d25 Add Turkish translation 2018-08-12 11:05:29 +00:00
Jordan Petridis
acaa06749e
ShowWidget: Keep the Frame from filling available the space 2018-08-12 02:41:22 +03:00
Piotr Drąg
1bd6efc0c1 Add Polish translation 2018-08-11 16:43:15 +00:00
Jiri Grönroos
936960269d Add Finnish translation 2018-08-11 14:10:14 +00:00
Jordan Petridis
5780df20ad Merge branch 'wip/piotrdrag/gschema-gettext' into 'master'
gschema: Add gettext-domain

See merge request World/podcasts!56
2018-08-11 09:24:05 +00:00
Piotr Drąg
14e5f33f2a gschema: Add gettext-domain
Without it DConf Editor won’t show localized schemas.
2018-08-11 01:35:28 +02:00
Jordan Petridis
7aa86bcec4 Merge branch 'wip/piotrdrag/update-potfiles-180810' into 'master'
Update POTFILES.in

See merge request World/podcasts!54
2018-08-10 23:20:38 +00:00
Piotr Drąg
c77a1e85a4 Update POTFILES.in 2018-08-10 20:58:18 +00:00
Jordan Petridis
093b8cb6df Merge branch 'wip/piotrdrag/desktop-comments' into 'master'
desktop: Add translator comments

See merge request World/podcasts!55
2018-08-10 20:56:30 +00:00
Piotr Drąg
6460198e1d desktop: Add translator comments 2018-08-10 22:03:04 +02:00
Jordan Petridis
662dc3fa85
HomeView: Properly align the frames
d5ea0d5a17 broke alignment
between the listboxes and the frame labels.
2018-08-10 22:49:54 +03:00
Merge Bot, Bors Wannabe
5a4bce2816 Merge branch 'alatiera/libhandy' into 'master'
Refactor views and use HdyColumn for views with that display `EpisodeWidget`s

Closes #70

See merge request World/podcasts!48
2018-08-10 18:35:58 +00:00
Jordan Petridis
d5ea0d5a17
HoveView and ShowWiget: Add margins around the listboxes 2018-08-10 21:14:49 +03:00
Jordan Petridis
9e525727fd
InAppNotif: allow the text to elipsize
Also fixe deprications in the glae file, add license
2018-08-10 20:49:59 +03:00
Jordan Petridis
95b6995649
Headerbar: Elipsisize update label 2018-08-10 15:54:12 +03:00
Jordan Petridis
745064c5ce
AddPopOver: Remove deprication warning 2018-08-10 15:47:20 +03:00
Jordan Petridis
223a3b46bf
Headerbar: Elipsisize show title 2018-08-10 15:46:28 +03:00
Jordan Petridis
fee3e320ab
ShowWidget: Move the listbox back to the glade file 2018-08-10 15:43:19 +03:00
Jordan Petridis
e068cff37b
ShowWidget: Increase the description char limit 2018-08-10 14:44:02 +03:00
Jordan Petridis
28f08ed196
HomeView: handle vadjustment with BaseView
Instead of using lazy_static to save the adjustment,
pass it to the widget upon creation. If its the first instance
created, pass None instead.
2018-08-10 14:43:58 +03:00
Jordan Petridis
056d971000
HomeView: make base view field publick
There is no point to re-export BaseView's methods.
2018-08-10 14:42:57 +03:00
Jordan Petridis
aa5195e5a9
ShowsView: handle vadjustment with BaseView
Instead of using lazy_static to save the adjustment,
pass it to the widget upon creation. If its the first instance
created, pass None instead.
2018-08-10 14:42:52 +03:00
Jordan Petridis
85aaaf80ac
ShowsView: make base view field publick
There is no point to re-export BaseView's methods.
2018-08-10 13:54:19 +03:00
Jordan Petridis
5d467a22d0
ShowWidget: make base view field publick
There is no point to re-export BaseView's methods.
2018-08-10 13:54:14 +03:00
Jordan Petridis
bcc1cfb67b
ShowWidget: handle vadjustment with BaseView
Instead of using lazy_static to save the adjustment,
pass it to the widget upon creation. If previous it doesn't
exists pass None instead.
2018-08-10 13:54:07 +03:00
Jordan Petridis
70a24fba69
BaseView: implement a set_adjustments method
Ment to replace the individual set_vadjustment of widgets.
Also remove unused method from ShowWiget.
2018-08-10 13:53:25 +03:00
Jordan Petridis
c3121bef84
ShowsChild: Remove the need for a .ui file
Its a simple enough widget that can be written by hand
and does not need a .ui builder file.
2018-08-10 13:53:12 +03:00
Jordan Petridis
8c25be7d05
BaseView: Set scroll policy 2018-08-10 13:53:00 +03:00
Jordan Petridis
7463e9d42c
ShowsView: Use BaseView abstraction 2018-08-10 13:52:56 +03:00
Jordan Petridis
e4dd9f5bb3
HomeView: Use libhandy::Column for the main widget 2018-08-10 13:52:52 +03:00
Jordan Petridis
4e59d648ef
HomeView: Use the new BaseView abstraction 2018-08-10 13:52:48 +03:00
Jordan Petridis
7538e76537
Gtk: Abstract a BaseView Widget
Currently it just re-exports its children with getters,
but the idea is that it will also be able to handle
the saving the state of its height adjustment later.
2018-08-10 13:52:44 +03:00
Jordan Petridis
20ddc54edc
ShowWidget: Put the whole thing inside HdyColumn 2018-08-10 13:52:35 +03:00
Jordan Petridis
ac75205933
ShowWidget: Initial prototype w/ libhandy::Column 2018-08-10 13:47:59 +03:00
Jordan Petridis
17b58b159a
ShowWidget: Remove unsused menu from the glade file 2018-08-10 13:47:12 +03:00
Jordan Petridis
191cf445ef
ShowWidget: Do not hardcode the episode listbox in the glade file 2018-08-10 13:47:07 +03:00
Jordan Petridis
65a0c08cb3
Flatpak: fix previous commit that broke it
I broke both the manifest and the CI in 4da68ff89c
2018-08-10 13:44:48 +03:00
Jordan Petridis
4da68ff89c
Flatpak: switch to using the 3.28 runtime again
Gtk was upgraded yesterday to 3.24. This causes a warning about
widgets being drawn without prior allocation. I am not sure yet if
the bug is in the bindings or if gtk's ABI broke.
2018-08-10 12:11:08 +03:00
Jordan Petridis
a7da4525fd
Flatpak: Grant access to thee dri
A warning suggested it, I assume it was coming from Gtk.
2018-08-10 12:08:54 +03:00
Jordan Petridis
92dfbce45a Merge branch 'wip/piotrdrag/review-strings' into 'master'
podcasts-gtk: Review ALL the strings!

See merge request World/podcasts!53
2018-08-10 05:58:11 +00:00
Piotr Drąg
bfdd6b5f7c podcasts-gtk: Review ALL the strings! 2018-08-10 05:23:15 +00:00
Jordan Petridis
a8a8c09b90 Merge branch 'wip/piotrdrag/appdata-fix' into 'master'
appdata: Remove duplicate tag

See merge request World/podcasts!49
2018-08-10 05:21:47 +00:00
Piotr Drąg
4bd13b81cf
appdata: Remove duplicate tag 2018-08-10 08:00:32 +03:00
Jordan Petridis
5a07600664 Merge branch 'wip/piotrdrag/unmark-placeholders' into 'master'
episode_widget: Unmark placeholder strings from translation

See merge request World/podcasts!50
2018-08-10 04:59:39 +00:00
Piotr Drąg
f2bc4b21cb episode_widget: Unmark placeholder strings from translation 2018-08-10 04:37:32 +00:00
Jordan Petridis
778975fbde Merge branch 'wip/piotrdrag/unmark-copyright' into 'master'
aboutdialog: Unmark copyright string from translation

See merge request World/podcasts!51
2018-08-10 04:36:54 +00:00
Piotr Drąg
57f920624f aboutdialog: Unmark copyright string from translation 2018-08-10 04:05:31 +00:00
Jordan Petridis
3d87940da9 Merge branch 'wip/piotrdrag/translate-appname' into 'master'
aboutdialog: Translate the app name

See merge request World/podcasts!52
2018-08-10 04:04:04 +00:00
Piotr Drąg
c33d0836eb
app: Translate the app name 2018-08-10 06:34:02 +03:00
Jordan Petridis
a01cf21f2c
Flatpak: use master runtime of GNOME sdk 2018-08-09 10:25:02 +03:00
Jordan Petridis
10fc1d4a2a
Gitlab: Add merge request template 2018-08-09 10:23:34 +03:00
Jordan Petridis
4fa973007d
App: Make sure to quit on delete event.
Since 9a76c6428a I've noticed
the applciation does not quit properly, and then it's unable
to be launched again till it's killed manually.

This patch is an attept to correct/workaroud that by calling
explcitly gio::ApplicationExt::quit().
2018-08-06 01:01:21 +03:00
Jordan Petridis
c27f5ec02e Merge branch 'readme_improvements' into 'master'
Fix some typos in README.md

See merge request World/podcasts!47
2018-08-05 08:57:26 +00:00
Matthew
5d6cf3d17d
Fix some typos in README.md 2018-08-05 11:32:42 +03:00
Jordan Petridis
f6c7731377
Fix Rust 2018 edition warnings 2018-08-05 11:24:20 +03:00
Jordan Petridis
bcd739da76 Merge branch 'i18n' into 'master'
Translation support and initial spanish translation

See merge request World/podcasts!46
2018-08-02 14:54:41 +00:00
Daniel García Moreno
bea4915317 Translation support and initial spanish translation
Added translation support based on the Fractal i18n. To do this I've
added the gettext-rs crate dep. I'm using my own fork because the
official gettext-rs release includes the gettext source files and that
increase the distribution package a lot and for distribution with
flatkap we don't need to build gettext, the lib is in the gnome sdk. So
this gettext-rs fork is the same, but removing the not needed gettext
source files.

The i18n.rs file adds some useful functions to translate strings. These
functions wraps the original gettext and adds more functionality, to be
able to translate compound strings, something that's not supported by
the gettext function.

The 'i18n' function works like the gettext, receives a plain string
without params.

The 'i18n_f' function receives a string with "{}" and a ref to an array
of &str with substitutions for the "{}" in the original string. The
substitution is done by order.

The 'i18n_k' function receives a string with "{named}" and a ref to an
array of (&str, &str) with substitutions for the "{named}" in the
original string. The substitution is done by name, where the first &str
in the tuple is the name and the second the string to use for the
replace.

This mod also include ni18n variants of the three functions for plural
and singular translations.

I've also created the spanish translation.

See #61

https://gitlab.gnome.org/World/podcasts/issues/61
2018-08-02 15:24:19 +02:00
Jordan Petridis
f695ba4605
cargo fmt 2018-08-02 07:10:22 +03:00
Jordan Petridis
24983ba3af Readme.md: Improve wording and formatting 2018-08-02 02:44:29 +00:00
Jordan Petridis
0a2a3b3377
Remove unused permission. 2018-08-01 02:14:27 +03:00
Jordan Petridis
d8d7193cbc
README: Add flahub instructions. 2018-07-31 11:40:05 +03:00
Jordan Petridis
042c9eed9c
Appdata: Remove 'lang=' metadata tag from the description.
This seems to confuse the flathub store website.
2018-07-31 11:22:15 +03:00
Jordan Petridis
b0c94dd998
Appdata: Add OARS Rating
This help with content age rating. Usefull for store
clients like gnome-software.

https://hughsie.github.io/oars/
2018-07-31 10:58:04 +03:00
Jordan Petridis
4c8cc9e823
Version bump. 2018-07-31 00:31:44 +03:00
Jordan Petridis
132c9bbdff
ShowModel: Remove dead unused methods. 2018-07-30 23:23:58 +03:00
Jordan Petridis
cc0caff8d0
EpisodeModels: Remove unused methods. 2018-07-30 23:20:10 +03:00
Jordan Petridis
3496df24f8
App: Fix refference cycles in actions. 2018-07-28 19:52:37 +03:00
Jordan Petridis
e4e35e4c57
App: Replace action macro with a generic fuction. 2018-07-28 19:52:27 +03:00
Jordan Petridis
838320785e
Replace SendCell with fragile.
SendCell is depricated now and its advised to use the Fragile
crate instead.

https://github.com/sdroege/send-cell/issues/5
2018-07-28 19:46:01 +03:00
Jordan Petridis
91bea85519
Update dependancies. 2018-07-28 19:15:26 +03:00
Jordan Petridis
88ea081661
Version bump. 2018-07-28 00:12:08 +03:00
Jordan Petridis
39c0a0dba5
Fix cargo vendor config. 2018-07-28 00:08:48 +03:00
Jordan Petridis
9d64d3e30d
AppStream: captions were too short. 2018-07-27 21:45:33 +03:00
Jordan Petridis
39ff238716
Version bump. 2018-07-27 20:21:28 +03:00
Jordan Petridis
7c96152f3f
Gtk: Change the minimum requests of the views. 2018-07-27 19:46:18 +03:00
Jordan Petridis
a2440c19e1
Appdata: Fix screenshots metadata. 2018-07-27 00:42:54 +03:00
Jordan Petridis
24dff5ce85
Update the release script. 2018-07-27 00:27:42 +03:00
Tobias Bernard
1ca8e15d19 Update org.gnome.Podcasts.appdata.xml 2018-07-26 21:15:10 +00:00
Tobias Bernard
f77a8f09bb Update podcasts.doap 2018-07-26 21:13:04 +00:00
Jordan Petridis
48a7c8140f
Update screenshots. 2018-07-26 23:53:08 +03:00
Jordan Petridis
b03ff46767
Version bump. 2018-07-26 08:33:45 +03:00
Jordan Petridis
e66e6364c3 Update Changelog 2018-07-26 05:30:46 +00:00
Jordan Petridis
89ef7ac4f6
Rename the .doap file. 2018-07-26 07:56:00 +03:00
Jordan Petridis
27e74ee064
Update the appdata summary. 2018-07-26 07:53:29 +03:00
Jordan Petridis
83c44aa12c
Replace links again 2018-07-26 07:41:43 +03:00
Jordan Petridis
17da62d53b
App: Fix some refference cycles. 2018-07-26 07:19:43 +03:00
Jordan Petridis
d43fc268f4
App: Only execute .show_all() at startup.
If you would had an instance running and activate the app again
the UI would break visually in all kind of ways.
2018-07-26 07:07:56 +03:00
Jordan Petridis
5a7ab9795d
Headerbar: rename menu-button to hamburger. 2018-07-25 04:57:00 +03:00
Jordan Petridis
cfcdba5aea
Headerbar: Avoid code duplication and ref cycles. 2018-07-25 04:38:24 +03:00
Jordan Petridis
aaca6a6704
Prefs: Use weak refs to avoid refference cycle. 2018-07-25 04:25:40 +03:00
Jordan Petridis
5f7c822deb
gtk: Rename left over home_view .ui files. 2018-07-25 03:55:42 +03:00
Jordan Petridis
53be091a31
Replace links 2018-07-25 03:26:41 +03:00
Jordan Petridis
04c68ba013
Initial massive renaming. 2018-07-25 03:26:35 +03:00
Jordan Petridis
518ea9c8b5
EmptyView: Improve wording of strings and Center stuff. 2018-07-24 15:07:13 +03:00
Jordan Petridis
6bb2142f35
Prefs: Fix typo. 2018-07-24 15:06:56 +03:00
Jordan Petridis
6aa931c866
Update deps. 2018-07-24 09:28:25 +03:00
Jordan Petridis
67ab54f820
Headerbar: Try to improve the add button behavior. 2018-07-24 09:05:55 +03:00
Jordan Petridis
cc9fc80328
Remove some unwraps. 2018-07-24 08:04:31 +03:00
Jordan Petridis
3e8a8a6b85
Hamburger: connect the MenuModel during initialization. 2018-07-24 07:36:41 +03:00
Jordan Petridis
70a2d0e5f3
EpisodeWidget: Improve total_size label wording. 2018-07-24 07:29:52 +03:00
Jordan Petridis
09e8d7e1da
AboutDialog: Update authors list. 2018-07-24 07:25:57 +03:00
Jordan Petridis
3c3d6c1e7f
EmptyView: Style improvments.
Suggest using the OPML import action from the hamburger menu.

Minor rework of the over style.
2018-07-24 07:23:00 +03:00
Jordan Petridis
0dcc95cd34
Hamburger: Hide unimplemented menu actions. 2018-07-24 06:47:43 +03:00
Jordan Petridis
edae1b0480
Add Application Icons.
Huge thanks to Tobias and Sam for these!
2018-07-24 06:27:34 +03:00
Jordan Petridis
5fb2cb7e76 Merge branch 'prefs' into 'master'
Prefrences dialog

See merge request World/hammond!34
2018-07-24 02:57:29 +00:00
Jordan Petridis
403bb71c5d
Prefs: Bikeshed on the naming of things. 2018-07-24 05:05:52 +03:00
Jordan Petridis
9e23b16ae7
App: Remove no longer needed allow(unused). 2018-07-24 05:05:43 +03:00
Jordan Petridis
60a93a2433
App: Use the gio::prelude 2018-07-24 05:05:39 +03:00
Zander Brown
14dfafcb7c
Sort out accelerators 2018-07-24 05:05:34 +03:00
Zander Brown
dcc06cf8c6
Initial dialog 2018-07-24 05:05:30 +03:00
Zander Brown
54c084040c
Minor rustfmt update 2018-07-24 05:05:26 +03:00
Zander Brown
a9c38f5a03
Remove the update section 2018-07-24 05:05:22 +03:00
Zander Brown
bce80cca0b
Fix some style issues 2018-07-24 05:05:19 +03:00
Zander Brown
f56fac6877
Bind GtkSettings dark property to GSettings
Now you can switch between dark and light on the fly
2018-07-24 05:05:15 +03:00
Zander Brown
ef2286dca4
Bind refresh/cleanup rate 2018-07-24 05:05:11 +03:00
Zander Brown
a4a012368e
Bind switches to keys 2018-07-24 05:05:08 +03:00
Zander Brown
1b623ef346
Initial dialog 2018-07-24 05:05:00 +03:00
Jordan Petridis
f661d24544 Merge branch 'icons-are-weird' into 'master'
Get the symbolics to work

See merge request World/hammond!45
2018-07-24 01:53:01 +00:00
Zander Brown
cfe10553b2
Stop overriding the images 2018-07-24 04:01:16 +03:00
Jordan Petridis
cf1042f40d
Player: Improve the tooltips of buttons. 2018-07-23 23:36:52 +03:00
Jordan Petridis
e77000076b
Player: Add custom icons for the forward and rewind buttons. 2018-07-23 21:28:29 +03:00
Jordan Petridis
49241664dc Merge branch 'alatiera-master-patch-41773' into 'master'
Add Sam to the artists credits.

See merge request World/hammond!44
2018-07-21 23:19:11 +00:00
Jordan Petridis
ffa3e9ec9a Add Sam to the artists credits. 2018-07-21 23:02:56 +00:00
Jordan Petridis
fbbe0d9ca9
EpisodeWidget: Tweak padding and spacing. 2018-07-21 21:14:07 +03:00
Jordan Petridis
001eeecc09
ShowMenu: Add a separator and set alignment left. 2018-07-21 20:12:02 +03:00
Jordan Petridis
454a9c7076
ShowWidget: Fix description padding. 2018-07-21 19:41:15 +03:00
Jordan Petridis
fc934ce8e1
ShowsView/Stack: Add some assertions. 2018-07-21 10:39:42 +03:00
Jordan Petridis
b9bcc28e0f
ShowMenu: Add debug assertions here too. 2018-07-21 10:30:46 +03:00
Jordan Petridis
b5ddca65f5
ShowWidget: Add some assertions. 2018-07-21 10:24:48 +03:00
Jordan Petridis
536805791e
ShowWidget: Move controls into a headerbar menu.
This fits better the HIG and allows for more flexibility in the way
the ShowWidget is implemented/designed.
2018-07-21 09:47:08 +03:00
Jordan Petridis
5a6c73c4c1
Headerbar: Move/Rename the menus.ui to hamburger.ui 2018-07-21 07:20:48 +03:00
Jordan Petridis
d50f5a0488
Player: Remove no longer relavant FIXME annotations. 2018-07-19 22:14:17 +03:00
Jordan Petridis
671a31a95a
Player: Improver human-facing error message. 2018-07-18 16:16:33 +03:00
Jordan Petridis
5e38f41530 Merge branch 'lazy_drawing' into 'master'
Lazy drawing

See merge request World/hammond!43
2018-07-18 13:14:44 +00:00
Jordan Petridis
7569465a61
App: Remove the imposed delay before refresh_on_startup runs.
The application is even lazier now and this is no longer an issue.
2018-07-17 20:42:57 +03:00
Jordan Petridis
5913166a13
EpisodeWidget: Avoid Refference Cycles.
When passing an Rc into a gtk callback it causes
the Rust struct to be kept in memory even if the gtk+ wiget
was dropped.

Should have been using Weak refferences all along.
2018-07-17 20:42:45 +03:00
Jordan Petridis
6036562af2
HomeEpisode: Do not initialize the Image until it needs to be drawn. 2018-07-17 16:08:20 +03:00
Jordan Petridis
39e6c258d5
ShowsChild: do not initialize the cover until it needs to be drawn. 2018-07-17 16:04:04 +03:00
Jordan Petridis
1ab0291483
EpisodeWidget: Only initialize the episode once it's first drawn. 2018-07-17 15:57:52 +03:00
Jordan Petridis
fe024502d4
EpisodeWidget: Pass the EpisodeModel by value.
This is preparationg for being able to pass the model to
the connect_craw callback directly.
2018-07-16 16:01:32 +03:00
Jordan Petridis
9a76c6428a
Merge branch 'hammond-service-launch'
See merge request !42
2018-07-15 22:21:18 +03:00
Jordan Petridis
2d4053c792
cargo fmt 2018-07-15 22:20:45 +03:00
Zander Brown
a69254612c
Install as a DBus service 2018-07-15 22:20:31 +03:00
Zander Brown
09a14c1270
Delay showing window until ::activate
Bit of a hack as we are still creating the window in ::startup but it's good compromise

Pass the arguments to GApplication so we can be launched as a service
2018-07-15 22:20:28 +03:00
Jordan Petridis
b343068805
cargo fmt 2018-07-15 22:17:00 +03:00
Jordan Petridis
ecf50dde2b
Update .gitignore 2018-07-15 22:16:06 +03:00
Jordan Petridis
008404ffb3
Update Changelog. 2018-07-04 18:40:03 +03:00
Jordan Petridis
2b6cca6bab
Version bump. 2018-07-04 17:26:56 +03:00
Jordan Petridis
fe968e19c0
Update Changelog. 2018-07-04 16:39:19 +03:00
Jordan Petridis
479498d8be
EpisodeMinimal: add lenght as well. 2018-07-03 16:38:08 +03:00
Jordan Petridis
af9669acd0
Add a bash script to run the test-suite. 2018-07-01 00:55:57 +03:00
Jordan Petridis
d2eb98f859
Update gitignore file. 2018-07-01 00:54:28 +03:00
Jordan Petridis
ae11084f48 Merge branch 'db-cleanup' into 'master'
Database cleanup

See merge request World/hammond!41
2018-06-30 21:25:51 +00:00
Jordan Petridis
b02b554105
Models: Change the Query suffix to Model.
This is maps better to the MVC naming convention.
2018-06-30 23:02:13 +03:00
Jordan Petridis
2d66ba918a
Models: Rename Podcat Models to Show to better match the UI code. 2018-06-30 22:47:58 +03:00
Jordan Petridis
400c0f35f0
PodcastModel: Remove dead code. 2018-06-30 22:20:07 +03:00
Jordan Petridis
5b8b265371
Database: Add diesel_cli config. 2018-06-30 21:47:57 +03:00
Jordan Petridis
f3fb27005a
Database: Rename the tables to better match the userfacing strings 2018-06-30 21:47:49 +03:00
Jordan Petridis
79bb9bdde8
Database: Drop dead fields/columns.
If and when such featured are going to be implemented, it should be
trivial to re-add them. Till then there is no reason for them to exist.
2018-06-30 18:05:52 +03:00
Jordan Petridis
4b983e401d
PlayerWidget: Use weak ref counting for callbacks.
When we pass strong reference to callback closures, it prevents
the object to be dropped even if they gtk Widget was destroyed.
2018-06-27 23:06:27 +03:00
Jordan Petridis
5f2f0a9a57
Headerbar: Hide the hamburger button when not in a global view. 2018-06-27 20:27:16 +03:00
Jordan Petridis
8b2ae6d464
Headerbar: Remove the requirment of a window to construct it. 2018-06-27 20:19:03 +03:00
Jordan Petridis
91aae6a9f5
Headerbar: Factor out the AddPopover widget. 2018-06-27 20:15:42 +03:00
Jordan Petridis
b0fc9ef05e
Headerbar: Factor out the Update Indicator widget. 2018-06-27 18:51:56 +03:00
Jordan Petridis
5d6fbb6f04
Headerbar: ::new() method now returns Rc<Self>. 2018-06-27 18:20:45 +03:00
Jordan Petridis
301ebdbcd8
Content: ::new() method now returns Rc<Self>. 2018-06-27 18:18:11 +03:00
Jordan Petridis
49bcf46b4f
app.rs: Group the gactions declarations. 2018-06-27 17:11:32 +03:00
Jordan Petridis
f7263c8ab8
app.rs: Move the action channel to it's own function. 2018-06-27 16:48:35 +03:00
Jordan Petridis
c69772131a
app.rs: Refactor into an object/struct again. 2018-06-27 16:32:08 +03:00
Jordan Petridis
e8c025b898
app.rs: Minor style change. 2018-06-26 23:37:14 +03:00
Jordan Petridis
8fb5c16bce
Upgrade crossbeam-channel. 2018-06-26 23:37:06 +03:00
Jordan Petridis
f4551ddf3a
Update deps. 2018-06-26 23:37:02 +03:00
Jordan Petridis
f337488951 Readme: Update the dependencies needed 2018-06-25 17:35:54 +00:00
Jordan Petridis
f104f11613
Fix trivial_cast lint warning. 2018-06-24 20:01:27 +03:00
Jordan Petridis
32e8f952fd
Even more lints! 2018-06-24 02:21:27 +03:00
Jordan Petridis
c7cfc81c6f Merge branch 'embedded-player' into 'master'
Embedded player

Closes #38

See merge request World/hammond!40
2018-06-23 23:03:39 +00:00
Jordan Petridis
faeafc329c PlayerWidget: Tweak rewind on pause behavior.
Only rewind on pause if the stream position is passed a certain point.
Else it can feel a bit weird if you just started the stream and it
immediatly rewinds.
2018-06-23 22:45:21 +00:00
Jordan Petridis
eeef0d13ff PlayerWidget: Use the Gtk Main Context for the gst_player as well.
There is no longer a need for sending stuff to the main-thread `Action`
channel anymore thanks to this.
2018-06-23 22:45:21 +00:00
Jordan Petridis
0686fca3b0 h-gtk: Increase the polling rate of the main thread channel.
When dragging the PlayerWidget.timer.slider widget PlayerDurationChanged
messaged pile up in the channel and get out of sync with the
slider.connect_value_changed() signal.
2018-06-23 22:45:21 +00:00
Jordan Petridis
79b425326b Update Changelog. 2018-06-23 22:45:20 +00:00
Jordan Petridis
2d879b9604 PlayerRate: Change the container widget to GtkBox and add padding. 2018-06-23 22:45:20 +00:00
Jordan Petridis
38eb14b013 Delete commented out code. 2018-06-23 22:45:19 +00:00
Jordan Petridis
ff2f43766e PlayerWidget: Add a widget to change the playback speed of the stream.
Only 3 options are offered currently since the design of the feature
is still in progress and this is only a throw a away prototype.
2018-06-23 22:45:19 +00:00
Jordan Petridis
593d66ea54 EpisodeWidget: Mark an episode as played when the play button is hit.
Ideally episodes would be marked as played only when they have
passed a cerain point in their duration, but till thats ready
we should keep marking them.
2018-06-23 22:45:19 +00:00
Jordan Petridis
ee8cbbf7ef PlayerWidget: Delete commented out stuff. 2018-06-23 22:45:19 +00:00
Jordan Petridis
474cb49d2c PlayerInfo: Increase the size of the cover. 2018-06-23 22:45:19 +00:00
Jordan Petridis
590f815dc0 PlayerInfo: Limit label widths and add tooltips. 2018-06-23 22:45:18 +00:00
Jordan Petridis
a93d5246d2 PlayerInfo: Swap bold properties of the labels. 2018-06-23 22:45:18 +00:00
Jordan Petridis
3e2ab8e7ee PlayerExt: Add a stop method.
Also connect on the gst_player::Player::connect_on_stream_end method.

The main reason for this is to be able to reset the slider bar to 0
upon a stream ends.
2018-06-23 22:45:18 +00:00
Jordan Petridis
a83270699f PlayerWidget: refactor seek method. 2018-06-23 22:45:17 +00:00
Jordan Petridis
745afb32a3 PlayerWidget: Rewind on pause. 2018-06-23 22:45:17 +00:00
Jordan Petridis
2fcb8d915d PlayerWidget: Remove an .expect() occurrence. 2018-06-23 22:45:17 +00:00
Jordan Petridis
a596b62a5f PlayerWidget: Tweak gst_player config. 2018-06-23 22:45:16 +00:00
Jordan Petridis
55b1504aab PlayerTimes: Display human-friendly values. 2018-06-23 22:45:16 +00:00
Jordan Petridis
c42822669b PlayerTimes: Replace unnecessary Arc with Rc. 2018-06-23 22:45:16 +00:00
Jordan Petridis
b58d28c723 PlayerTimes: create wrapper struct of gst::ClockTime.
Now on_duration_changed requires a `Duration` type and
on_position_updated requires a `Position` type as oppose to
both accepting `ClockTime` as their argument.
2018-06-23 22:45:16 +00:00
Jordan Petridis
a6a34d8246 PlayerWidget: Group the button connect_clicked methods. 2018-06-23 22:45:16 +00:00
Jordan Petridis
0080399db2 PlayerWidget: Move on_duration_change and on_postion_updated methods.
Previously they depended on the player/pipeline to get the ClockTime
values, and only `PlayerWidget` had access to the `gst_player::Player`
object.

Now that it uses the gst_player methods instead of the raw pipeline
methods to get the ClockTime values it no longer needs access to the whole
PlayerWidget object.
2018-06-23 22:45:15 +00:00
Jordan Petridis
50b480ee23 PlayerWidget: Refactor the position_changed/updated callback.
It now uses gst_player::Player::connect_position_updated callback
to send, cross threads, the `position` value to the gtk main loop
which then updates the widget.
2018-06-23 22:45:15 +00:00
Jordan Petridis
da467b7837 PlayerWidget::seek handle the case where the offset might be none. 2018-06-23 22:45:15 +00:00
Jordan Petridis
70914b6c3e PlayerWigdet: Refactor the way the duration label is updated.
This now connect's directly to gst_player::Player::connect_duration_changed
method.

The method then sends a cross-thread msg to the Action channel in the main loop that
then updates the widget.
2018-06-23 22:45:14 +00:00
Jordan Petridis
6c3fbfe0ca PlayerWiget: refactor the seekbar connect signal. 2018-06-23 22:45:14 +00:00
Jordan Petridis
8e4b705e60 PlayerWidget: Log the gst warnings. 2018-06-23 22:45:14 +00:00
Jordan Petridis
48d80d3194 PlayerWidget: Remove unused vars an Enum. 2018-06-23 22:45:14 +00:00
Jordan Petridis
38768c777d PlayerWidget: Connect the fast-forward and rewind buttons, sort of.
There appears to be a bug where it seeks 17 seconds instead of 10.
2018-06-23 22:45:13 +00:00
Jordan Petridis
1daa841f31 PlayerWidget: Connect to the errors callback. 2018-06-23 22:45:13 +00:00
Jordan Petridis
a9f81d0ad3 PlayerWidget: Refactor the timers callbacks.
Should use the gst_player::Player callbacks instead but they require
the Send Trait which means we would need to use SendCell and that's
not something I am going to deal with right now.
2018-06-23 22:45:13 +00:00
Jordan Petridis
76720424ab PlayerWidget: Set a custom config for the gst Player. 2018-06-23 22:45:13 +00:00
Jordan Petridis
a7b639a66b PlayerWidget: Wire the PlayerTimes labels and scale.
Adapted from gstreamer basic-tutorial-5.
https://gstreamer.freedesktop.org/documentation/tutorials/basic/toolkit-integration.html
2018-06-23 22:45:12 +00:00
Jordan Petridis
1b78d221b6 PlayerWidget: Wire the play and pause buttons and add style classes to the Info Labels.
This also includes the yak shaving of a ::new and ::inti methods.
2018-06-23 22:45:12 +00:00
Jordan Petridis
ac7b1a3c66 CI: disable debian builds fow now.
Debian stable provides gst 1.10 but the gst-rs bindings requiere
v 1.12 to build.

I will make custom images Soon™
2018-06-23 22:45:12 +00:00
Jordan Petridis
039c3182aa h-gtk: Remove unused .ui file. 2018-06-23 22:45:12 +00:00
Jordan Petridis
55d94b1844 CI: Add gstreamer as a dep for the debian build. 2018-06-23 22:45:11 +00:00
Jordan Petridis
3baa69b43b cargo fmt 2018-06-23 22:45:11 +00:00
Jordan Petridis
5f92df97e6 PlayerWidget: Wire the widget to the GUI.
This commit also removes the majority of the playback widget,
though most of it's code will make it to the PlayerWidget once
it starts to get wired to the gtreamer_plaer::Player.
2018-06-23 22:45:11 +00:00
Jordan Petridis
47f297c495 PlayerWidget: Intial draft of the the PlayerExt trait. 2018-06-23 22:45:11 +00:00
Jordan Petridis
58f09ba150 h-gtk: Bind the new player widget to code. 2018-06-23 22:45:10 +00:00
Jordan Petridis
1142948945 Rework the player widget. 2018-06-23 22:45:10 +00:00
Zander Brown
9528160b03 Start hooking things up
Still doesn't accept input
2018-06-23 22:45:10 +00:00
Zander Brown
4afdc54914 Initial playback control area
(not plumbed in)
2018-06-23 22:45:10 +00:00
Zander Brown
09973a6a56 Initial playback
... and not a lot more. Hit play and the podcast will play, press play on something else and that will play instead
2018-06-23 22:45:09 +00:00
Jordan Petridis
f56bf3afef
Enable more rustc lints 2018-06-23 21:02:19 +03:00
Jordan Petridis
c4c7bbf46b
Content: Change the user-facing string of the home stack. 2018-06-23 16:11:00 +03:00
Jordan Petridis
7fdd374911 Update the gitlab bug issue template 2018-06-23 13:01:34 +00:00
Jordan Petridis
fb9ad9870d Update the gitlab feature issue template. 2018-06-23 12:59:35 +00:00
Jordan Petridis
bbabc6f5e9
h-gtk: Add resources/ to the cargo check list. 2018-06-11 14:23:29 +03:00
Jordan Petridis
2060579bb4
gitlab templates: Fix markdown formatting. 2018-06-09 16:16:57 +03:00
Jordan Petridis
b0f0940605 Merge branch 'master' into 'master'
Our GActions don't need to be in the app namespace

See merge request World/hammond!39
2018-06-07 17:37:12 +00:00
Zander Brown
ee23df176d Our GActions don't need to be in the app namespace 2018-06-07 18:06:36 +01:00
Jordan Petridis
d53865d81b
Update deps. 2018-06-07 16:14:40 +03:00
Jordan Petridis
7becfd8adb
Commit Updated Cargo.lock.
This was part of a9feed56fe but
forgot to commit it.
2018-06-07 15:47:41 +03:00
Jordan Petridis
a9feed56fe
Replace html2pango with html2text. 2018-06-06 15:28:44 +03:00
Jordan Petridis
dea517c17c
Update the Changelog. 2018-06-05 14:58:29 +03:00
Jordan Petridis
6d93ceb910
EpisodeWidget: Minor style change. 2018-06-05 14:33:18 +03:00
Jordan Petridis
9b0ac5b83d
EpisodeWidget: Do not lock the Proggress struck when running update callbacks.
Previously each time we wanted to inspect the `Progress` struct we
were blocking which was problematic since the downloader also wants
to block to update it.

Now we use try_lock() and if a lock can't be aquired we requeue another
callback. That way we can also be way more aggressive about the interval
in whihc it the callbacks will run.
2018-06-05 14:17:37 +03:00
Jordan Petridis
acabb40171 CI and Flatpak: Use the 3.28 runtime till fdo 1.8 is fixed. 2018-06-04 14:25:38 +00:00
Jordan Petridis
04cd56ca16 CI: change the image of the flatpak job. 2018-06-02 20:55:09 +00:00
Jordan Petridis
4371512ba2 Merge branch 'episode-wiget-refactor' into 'master'
Episode wiget refactor

See merge request World/hammond!38
2018-06-02 20:42:46 +00:00
Jordan Petridis
ced686e1cd
EpisodeWidget: Remove explicit type declarations. 2018-06-02 22:33:00 +03:00
Jordan Petridis
272aab2397
EpisodeWidget: Document determine_state method. 2018-06-02 22:00:30 +03:00
Jordan Petridis
a7f87f2ac8
ShowWidget: Fix markallplayed functionality.
cfe79a73d6 changed the structure
of the EpisodeWidget and broke this.
2018-06-02 21:34:19 +03:00
Jordan Petridis
f9e85155a8
Remove unused dependancy. 2018-06-02 21:13:30 +03:00
Jordan Petridis
d281c18951
EpisodeWidget: Pass EpisodeWidgetQuery by refference. 2018-06-02 21:03:07 +03:00
Jordan Petridis
04e7f4f8a7
EpisodeWidget: Wire the download_checker callback again.
If an episode is being downloaded we setup a callback that will
supervise the widget and update it's state once the download action is
completed and the episode rowid is removed from `manager::ACTIVEDOWNLOADS`.
2018-06-02 21:03:00 +03:00
Jordan Petridis
a090c11f4a
EpisodeWidget: Wire the progressbar again. 2018-06-02 19:42:19 +03:00
Jordan Petridis
c303c697a9
EpisodeWidget: Wire the total_size label again.
The size might be provided by the rss feed but not alwasy. Additionally it might be
missleading so when a download starts we replace the label with the
HTTP ContentLength header.
2018-06-02 19:25:25 +03:00
Jordan Petridis
9466c5ea10
EpisodeWidget: Wire the cancel button. 2018-06-01 21:30:56 +03:00
Jordan Petridis
1268fcf1cc
EpisodeWidget: Wire the download button. 2018-06-01 19:08:56 +03:00
Jordan Petridis
86d06fa879
EpisodeWidget: Wire the play button again. 2018-06-01 16:49:06 +03:00
Jordan Petridis
cfe79a73d6
EpisodeWidget: Initial refactor.
State machines were a fun experiement but a nightmare to deal with
after the fact. This is the first steps for a refactor in a tradition
style with the goal to ultimatly making it easy to and port to relm.
2018-06-01 16:19:33 +03:00
Jordan Petridis
d7a9d9ddc8
CI: FIx the clippy cache. 2018-05-31 14:46:51 +03:00
Jordan Petridis
b3d45384e1
meson: Add debug build. 2018-05-31 14:45:54 +03:00
Jordan Petridis
64099e37e5 Merge branch 'dialog-enchance' into 'master'
Avoid un-closable AboutDialog

See merge request World/hammond!37
2018-05-31 09:42:52 +00:00
Zander Brown
2fe612d392 Avoid un-closable AboutDialog 2018-05-31 08:35:09 +00:00
Jordan Petridis
14d72b92cb
h-gtk: Move appnotif.rs into the widgets module. 2018-05-30 16:45:46 +03:00
Jordan Petridis
8c0055723c
cargo fmt 2018-05-30 16:25:15 +03:00
Jordan Petridis
bb9e368b2d
Merge branch 'ZanderBrown/hammond-gaction_macro' 2018-05-29 22:21:55 +03:00
Zander Brown
2c203acbd2 Use a macro when setting up simple SimpleActions 2018-05-29 17:16:05 +00:00
Jordan Petridis
7115eb573c
Downgrade Diesel to 1.2.x
disel_migrations 1.3 triggeres some clippy lints atm.
2018-05-29 18:16:57 +03:00
Jordan Petridis
e626c6f286
app.rs: Factor out the GAction definitions. 2018-05-29 14:04:09 +03:00
Jordan Petridis
24058f9534
h-gtk: Write doc comment for aboutdialog. 2018-05-29 13:36:22 +03:00
Jordan Petridis
a8d47e9a72
app.rs: Remove unused variable. 2018-05-29 13:26:36 +03:00
Jordan Petridis
9a2f51b48d
Update deps. 2018-05-28 22:06:07 +03:00
Jordan Petridis
667deef5f2
Use a mpmc channel instead of the mspc from the std. 2018-05-28 20:49:12 +03:00
Jordan Petridis
aa349aa935
Flatpak: Remove access to XDG_HOME.
In 75c50392cb we switched to use the
FileChooserNative API which means that filesystem access is automatically
handled by the Documents(!)(Might be another one) xdg portal.
2018-05-28 19:50:25 +03:00
Jordan Petridis
666ab01d03 Merge branch 'appmenu' into 'master'
Appmenu

See merge request World/hammond!33
2018-05-28 16:41:25 +00:00
Zander Brown
ffbab0136f Bind F10 to open the menu
Because we aren't using app-menu accels aren't automatically binded
2018-05-27 15:48:27 +01:00
Zander Brown
b5f7399b2c RIP appmenu
F5 -> <primary>r for refresh
2018-05-27 14:34:58 +01:00
Zander Brown
f1892eeba2 Always show hamburger menu 2018-05-22 10:46:50 +01:00
Zander Brown
e7128a57db Resolve some comments 2018-05-22 10:28:13 +01:00
Zander Brown
793cafd294 Formatting updates 2018-05-22 09:55:00 +01:00
Zander Brown
9b1097effe
Merge branch 'master' of https://gitlab.gnome.org/World/hammond into appmenu 2018-05-22 09:51:31 +01:00
Zander Brown
079ae0e1f3 Fallback to hamburger when the environment doesn't like app menus 2018-05-21 13:01:06 +01:00
Zander Brown
e181a9837a Merge upstream master 2018-05-21 12:01:32 +01:00
Zander Brown
ca5c7022ef Fixed some shortcut display issues
Also give FileChooserNative arguments in the right order & add F5 to refresh
2018-05-21 11:49:35 +01:00
Zander Brown
75c50392cb Everything works (ish)
Also use FileChooserNative for flatpak nicities
2018-05-21 10:06:10 +01:00
Jordan Petridis
74f8e744ac
Version bump. 2018-05-20 17:47:33 +03:00
Jordan Petridis
1c657036da
Update Changelog. 2018-05-20 17:40:27 +03:00
Jordan Petridis
2869bb3ef3
Flatpak: Allow the app to access the Home folder.
This is needed in order to import an OPML file. There
might be a better solution to this.
2018-05-20 17:37:48 +03:00
Jordan Petridis
784e117a8a
cargo fmt 2018-05-20 16:39:36 +03:00
Zander Brown
8c2ea052de Keyboard shortcut overview!
(shame everything else is broken...)
2018-05-20 13:59:00 +01:00
Jordan Petridis
0b6bbf6733
CI: Fix rustfmt job. 2018-05-20 14:36:20 +03:00
Jordan Petridis
2312df3718
Fix rustfmt config. 2018-05-20 14:32:06 +03:00
Zander Brown
095dd73c52 Move refresh 2018-05-19 22:11:44 +01:00
Zander Brown
ac6ac42860 Move import 2018-05-19 21:48:38 +01:00
Zander Brown
c6ce888cc7 Define an app-menu with About & Quit actions
Rename some paths for auto resource magic
2018-05-19 20:38:36 +01:00
Jordan Petridis
c4e6fcc451
Version bump. 2018-05-19 13:11:02 +03:00
Jordan Petridis
22ae5242b2
Update Changelog. 2018-05-19 12:44:14 +03:00
Jordan Petridis
4d77281249
Update deps. 2018-05-19 12:17:55 +03:00
Jordan Petridis
b77a373efa
Opml: remvoe uneccesary to_string invocation. 2018-05-17 15:08:38 +03:00
Jordan Petridis
463c3908d4
Revert "gitlabci: switch to test rust-nightly till the stable image is update."
This reverts commit 2711ebca68.

Rust stable image is updated again.
2018-05-16 21:00:35 +03:00
Jordan Petridis
79239a2e01
gitlabci: Cache cargo for the clippy job. 2018-05-16 20:59:54 +03:00
Jordan Petridis
41073615e9
Clippy: Derive Copy for appnotif::UndoState 2018-05-16 19:54:07 +03:00
Jordan Petridis
bd12b09cbc
ShowsView: Fix a bug where the last show would not be shown.
utils::lazy_load() now calls widget.show() for each widget it adds
to the parent container.
2018-05-16 19:44:58 +03:00
Jordan Petridis
7d598bb1d0
h-gtk: Smooth out some stack transitions. 2018-05-16 19:40:36 +03:00
Jordan Petridis
ccd3e3ab2c
h-gtk: Show error notifications when OPML import fails. 2018-05-16 17:54:32 +03:00
Jordan Petridis
118dac5a1a
app.rs: Add an action for showing error notification. 2018-05-16 17:30:43 +03:00
Jordan Petridis
7035fe05c4
InAppNotification: Extend the widget to allow showing notifications without an undo button. 2018-05-16 16:58:03 +03:00
Jordan Petridis
af7331c6c6
h-gtk: Rename EpisodeViewWidget to HomeEpisode. 2018-05-16 16:33:54 +03:00
Jordan Petridis
4d2b64e79d
h-gtk: Remember the vertical allingment of the ShowsView. 2018-05-13 22:47:56 +03:00
Jordan Petridis
d47bbd6131
Remove explicit and not needed inline calls.
This code is not performance critical and the compiler will already
inline whatever it thinks it might benefit it.
2018-05-13 22:08:25 +03:00
Jordan Petridis
54fafa07a2
h-gtk: Use clone! macro to replace some boilerplate. 2018-05-13 21:51:32 +03:00
Jordan Petridis
97eef9149c
import_dialog: Do not show hidden files in the FileChooser. 2018-05-13 00:22:54 +03:00
Jordan Petridis
b95e70a8c4
import_dialog: Only show xml files in the FileChooser. 2018-05-13 00:06:35 +03:00
Jordan Petridis
a16d7de1ac
Add EPIC.md issue template. 2018-05-12 23:15:53 +03:00
Jordan Petridis
6f590a64af
gitlabci: Fix review-app. 2018-05-12 23:01:50 +03:00
Jordan Petridis
00e747eb5f
h-gtk: Wire the import_shows button on the hamburger menu to the the opml import. 2018-05-12 22:55:35 +03:00
Jordan Petridis
2d8164cf0a
Clippy: remove useless attribute.
Also merge some attributes together.
2018-05-12 15:02:33 +03:00
Jordan Petridis
be1a8df3ef
Headerbar: simplify the naming scheme a bit.
The type of the widgets is already a Button so there's no need in
repeating that in the struct field.

Also remove some type annotations since the compiler can infer them
from the type of the struct fields.
2018-05-12 14:45:00 +03:00
Jordan Petridis
e8ca2faaa7
Headerbar: Add import and export items in the hamburger menu.
I strongly believe that these do not belong there and should be moved
elsewhere. But for the time being and prototyping they are "good enough".

People most of the time tend to import from an OPML file only on the first
use of the App. Then the functionality sits there and is never used again.
That's why I think import should be moved into a preferences dialogs and
have the empty view suggest the action.

Exporting OPML files is also not that common, I also believe this should be
moved into a preference dialog instead of the hamburger menu.
2018-05-12 14:17:40 +03:00
Jordan Petridis
83f9284b05
Cargo fmt 2018-05-12 13:45:19 +03:00
Jordan Petridis
6e5c70ab71
opml: Change the signature of import_opml function.
xml::reader::Error is the only error that can be returned so there
is no need to use the DataError type.
2018-05-12 13:19:14 +03:00
Jordan Petridis
ab4f958b5f
Readme: bump the minimum rust version. 2018-05-11 13:55:34 +03:00
Jordan Petridis
2711ebca68
gitlabci: switch to test rust-nightly till the stable image is update. 2018-05-11 13:54:25 +03:00
Jordan Petridis
041684b13a
Use impl Trait syntax instead of Trait Objects.
Rust v1.26 introduced impl Trait which avoid the heap allocation with Box
and makes the code a bit more ergonomic.

For more see https://blog.rust-lang.org/2018/05/10/Rust-1.26.html
2018-05-11 12:06:31 +03:00
Jordan Petridis
777a2102f8
gitlabci: Add review apps.
This makes it able to show a link pointing to the bundle in Merge Requests.
2018-05-10 18:46:52 +03:00
Jordan Petridis
65949c5af5
gitlabci: Extend the lifespan of the flatpak bundle.
For more information see the following nautilus commit.
3a35b6035a

Closes #60.
2018-05-10 18:46:52 +03:00
Jordan Petridis
e40f880b9e
gitlabci: Use prebuilt clippy image
Since clippy won't fail to install now that it's bundled in the
container image also do not allow the to fail
2018-05-10 18:38:51 +03:00
Jordan Petridis
f9b34bbd50
h-data: Initial implementation of an OPML parser and importer.
This is not really compiant with the OPML spec and there
does not seem to be an OPML crate sadly. There are edge-cases
that are not handled but will only be addressed if a problem is reported.
2018-05-10 18:17:19 +03:00
Jordan Petridis
f06dbd0562
Version bump. 2018-05-07 19:48:21 +03:00
Jordan Petridis
4a6f5c6268
Update the Changelog. 2018-05-07 18:46:09 +03:00
Jordan Petridis
b5dbfb1a86
PopulatedStack: Allow for more control over the stack transitions.
When you just update/replace the widget there is no need for an animation
to occur. Thus why animations where broken before. This commit is not ideal
as it makes it the responsibility of the caller to declare valid(UX wise)
transitions.
2018-05-04 11:32:50 +03:00
Jordan Petridis
d86a17f76e
ShowWidget: Set max_width_charters in the Description.
If the window is fullscreen or given a big width description
expands and becomes harder to read. This sets the character limit
of the description to 70charaters. The exact size might differ
from machine-to-machine based on user settings. (Hi-dpi, chosen
font, etc.)
2018-05-02 19:29:55 +03:00
Jordan Petridis
37c7f20256
gitlabci: Always run clippy as part of the lint state now. 2018-05-02 16:05:03 +03:00
Jordan Petridis
f324407c9c
Deny all the warnings when building. 2018-04-30 14:21:34 +03:00
Jordan Petridis
c96b39d597
Fix all the clippy warnings! 2018-04-30 14:13:54 +03:00
Jordan Petridis
d4d89a56e9
Revert "Update deps."
This reverts commit f19ad133c6.

There was dependancy conflitct that was not caught locally due to caching.
2018-04-29 20:15:44 +03:00
Jordan Petridis
f19ad133c6
Update deps. 2018-04-29 19:57:32 +03:00
Jordan Petridis
00d9019f29
Do not pass some things by value when not needed. 2018-04-29 19:27:40 +03:00
Jordan Petridis
8951a6e237
h-gtk: Animate the adjustment of scrolled windows.
Many thanks to Julian Spaber for documenting this.
2018-04-29 19:07:12 +03:00
Jordan Petridis
b0ac037964
Fix the broken test. 2018-04-28 14:53:43 +03:00
Jordan Petridis
2c8ff2d2f2
Cargo fmt 2018-04-28 14:19:55 +03:00
Jordan Petridis
03bd951848
EpisodeWidget: Handle updating states, withotu having to reload the views.
This code is ugly and terrible but it works™. Previsously when a download
would finish it would refresh all the views. Now the if the widget get's
into the Donwloading state, it will setup a callback that will check
periodicly if it's still downloading and update the widget state when
the episode stops downloading.
2018-04-28 14:09:26 +03:00
Jordan Petridis
63e2ea987e
This was commited by accident. 2018-04-28 12:41:56 +03:00
Jordan Petridis
115df7f884
h-gtk: Re-work the minimum widget requests. 2018-04-27 12:08:07 +03:00
Jordan Petridis
ed94d34589
h-gtk: Rename the stasckswitcher field in the Headerbar. 2018-04-27 11:41:39 +03:00
Jordan Petridis
dc5ff9d809
h-gtk: Take into account the ignored_shows when detemening if podcast table is empty.
If you've had one show and pressed unsub, instead of going to
an empty view, it would stay to populated since it the db records
where still there.
2018-04-27 11:21:32 +03:00
Jordan Petridis
72a6832571
h-gtk: Rename HomeView and ShowView parent modules. 2018-04-25 20:57:05 +03:00
Jordan Petridis
b0c692ab7d
Readme: Add matrix link. 2018-04-25 20:15:43 +03:00
Jordan Petridis
06e8bc14f4
Readme: Remove badges and tweak the description.
Gitlab 10.7 natively supports badges
2018-04-25 20:00:41 +03:00
Jordan Petridis
3d160fc35c
h-gtk: Restructure the stacks structure.
This commit reimplements support for the empty view in the ShowStack.
The current structure is the following:
* A Content stack which holds the HomeStack and the ShowStack.
  It's what is used in the headerbar StackSwitcher.

* The HomeStack is composed of the HomeView and an EmptyView.

* The ShowStack is composed of the PopulatedStack and an EmptyView.

* The PopulatedStack is composed of the ShowsView and the ShowWidget
  currently. An AboutEpisode widget is planned to be added here also.
2018-04-25 19:23:02 +03:00
Jordan Petridis
734f85a517
Fix logic typo. 2018-04-24 15:34:52 +03:00
Jordan Petridis
a56a80db88
ShowWidget: Keep track of the podcast it was created from.
Since ShowStack now keeps a refference to ShowWidget we no
longer need to encode it in the widget name.
2018-04-24 15:25:34 +03:00
Jordan Petridis
c4ed90dd5a
ShowStack: Refactor to make stack restructure easier.
This removes the empty widget from the ShowStack. The plan is
to have a ShowsView which will be the parent of ShowStack and
an Empty Widget. Then make ShowStack have only valid populated
states of ShowsPopulate, ShowWidget and AboutEpisodeWidget later.
2018-04-24 13:12:27 +03:00
Jordan Petridis
f173b326a4
Contnet: Minor renaming following 75af3c7f2b 2018-04-24 12:28:29 +03:00
Jordan Petridis
5e302290de
HomeStack: Minor refactor to wrap gtk::Stack actions.
For now the methods are private and migth not be neccesarry,
but it will be much easier to manipulate the stack from outside
with this API if it's needed and the methods are made public.
2018-04-24 09:59:29 +03:00
Jordan Petridis
75af3c7f2b
h-gtk: Rename EpisodeStack to HomeStack. 2018-04-24 09:31:56 +03:00
Jordan Petridis
d7aec6fdfb
h-gtk: Move vies inside the widgets module.
EpisodeView was renamed to HomeView. More renaming will follow.
2018-04-23 15:57:46 +03:00
Jordan Petridis
9a3586fd1c
Readme: Fix the flatpak instructions. 2018-04-23 13:35:04 +03:00
Jordan Petridis
063bcbd0e5
Remove unused pub field. 2018-04-22 06:34:02 +03:00
Jordan Petridis
a76e69e05d
ShowWidget: Center the description label. 2018-04-21 09:25:21 +03:00
Jordan Petridis
e560cce879
h-gtk: Further refactor of the ShowStack. 2018-04-21 08:01:34 +03:00
Jordan Petridis
6406c3af13
h-gtk: Refactor part of the ShowStack. 2018-04-21 07:40:42 +03:00
Jordan Petridis
173d2d3a3a
h-gtk: Refactor EpisodesStack. 2018-04-20 17:23:07 +03:00
Jordan Petridis
9a5cc1595d
ShowWidget: re-arrange the show's cover/desc/buttons widgets. 2018-04-20 10:20:09 +03:00
Jordan Petridis
d1962ab745
Remove some boilerplate. 2018-04-20 10:15:27 +03:00
Jordan Petridis
af5b27d0fc
EpisodeWidget: Replace Arc<Mutex<Widget>> with Rc<RefCell<Widget>>.
Since gtk Widgets are not Send, and the callbacks all run in the gtk
main loop, it *should* not be possible that 2 things try to access the
same widget at the same time.
2018-04-20 07:26:56 +03:00
Jordan Petridis
a9196d27d6 Merge branch 'master' into 'master'
Update links to new repo

See merge request World/hammond!32
2018-04-20 00:34:35 +00:00
Ivan Augusto
2f1ea12cfa Update links to new repo 2018-04-19 17:23:46 -03:00
Jordan Petridis
37a408e58a
dbquerries: Fix the is_populated() querries.
Thanks a lot to the diesel gittrer channel!
2018-04-19 14:45:00 +03:00
Jordan Petridis
736a993284
h-gtk: Move forgotten test from a68987f257 2018-04-19 08:26:45 +03:00
Jordan Petridis
09359a8df3
Update deps and bump rss crate.
My PR for Channelinto_items() went through and a new rss
release in is already available!
2018-04-19 08:07:02 +03:00
Jordan Petridis
a68987f257
h-gtk: Move some stuff from utils to settings module. 2018-04-19 08:04:40 +03:00
Jordan Petridis
3b5831f317
ShowsView: Do not block while loading ShowChilds. 2018-04-19 07:51:48 +03:00
Jordan Petridis
5336981154
h-gtk: Change the signature of utils::set_image_from_path to not require a Podcast.
It was only used to call the podcast.id() method. This allows EpisodeViewWidget
to be created whithout the need for a call to the database to get a Podcst
Object for each widget.
2018-04-19 07:15:12 +03:00
Jordan Petridis
df417fa619
h-gtk: Use Rc instead of Arc wherever possible.
As logn we are not doing anything funny to bypass the borrow-checker,
we should not be able to touch gtk wigets from other threads anyway.
2018-04-19 06:34:02 +03:00
Jordan Petridis
509bbe25d2
EpisodeView: Retain the scrollbar valignment. 2018-04-19 06:12:08 +03:00
Jordan Petridis
f49012ab51
EpisodeView: Reduce boilderplate. 2018-04-19 05:40:07 +03:00
Jordan Petridis
e4fc7c336e
EpisodeView: Fix empty state. 2018-04-19 05:32:27 +03:00
Jordan Petridis
0e4430bae4
EpisodeView: Initial refactor to make loading non-blocking. 2018-04-19 02:52:58 +03:00
Jordan Petridis
f811a9c8f4
Feed: Split index_channel_items method. 2018-04-18 08:20:26 +03:00
Jordan Petridis
771999c603
h-data: Move some functions from pipeline to feed module. 2018-04-18 07:35:53 +03:00
Jordan Petridis
18820202d7
gitlabci: Add needed ENV vars.
abc8fb988f uses an feature of rayon
that's behind a compile time flag.
2018-04-18 07:06:17 +03:00
Jordan Petridis
abc8fb988f
Pipeline: Dispatch feed indexing to the rayon threadpool. 2018-04-18 05:06:02 +03:00
Jordan Petridis
885c525d7b
Pipeline: change the signature of pipeline to accept future::Stream instead of IntoIterator. 2018-04-18 04:05:14 +03:00
Jordan Petridis
418a2f02b2
Pipeline: Add a bad feed test case. 2018-04-18 03:38:06 +03:00
Jordan Petridis
031078284c
Feed: Print an error in stderr before discarding it. 2018-04-18 03:03:05 +03:00
Jordan Petridis
3c7ba8c9d9
Feed: Convert index_channel_items to a Future/Steam impl. 2018-04-18 02:49:21 +03:00
Jordan Petridis
835078a84c
Pipeline: Convert the sources iterator into a Stream and return a Future
`futures::stream::iter_ok` is so conviniet, why had None told me
about it before?
2018-04-18 01:40:06 +03:00
Jordan Petridis
049418c2f5
Feed: clean up clunky impl of indexing episodes. 2018-04-17 12:05:10 +03:00
Jordan Petridis
7c03266d16
Inline a bunch of stuff. 2018-04-17 09:04:18 +03:00
Jordan Petridis
627f06ea9f
Fix typos. 2018-04-17 08:50:03 +03:00
Jordan Petridis
9f84178182
h-gtk: Increase the sleep time between the action channel calls. 2018-04-17 07:52:04 +03:00
Jordan Petridis
54641fc3c5
ShowWidget: Try to retain scrollbar adjustment. 2018-04-17 06:00:06 +03:00
Jordan Petridis
b8995d838a
ShowWidget: Move listbox population to widgets/show.rs 2018-04-17 04:44:55 +03:00
Jordan Petridis
a0154c5919
lazy_load: Add the ability to execute a callbakc on finish
When iteration of data is finished, None will be returned and
the or_else() block will be executed. Now a callback can be
passed that will be executd when the iteration/loading finishes.
2018-04-17 03:13:01 +03:00
Jordan Petridis
08365c412a
h-gtk:utils Add a more flexible implementation of lazy_load.
lazy_load_full is meant for siturations that you don't need
the constraisn of passing a single container parent and adding
a sigle widget to it.

Reimplemnted lazy_load on top of lazy_load_full.
2018-04-17 02:33:32 +03:00
Jordan Petridis
2d291a08fc
h-data: Refactor the Diesel helper traits to use Associated Types. 2018-04-17 01:33:50 +03:00
Jordan Petridis
7a17b3df4b
ShowWidget: Restore sensitivite of the unsub button.
If you clicked unsub, then undo and then the same show widget you
would navigated to the previous ShowWidget and the unsub button
would still be insensitive.
2018-04-16 07:43:30 +03:00
Jordan Petridis
0589f2fe2a
h-gtk: Move show notification creation into widgets/show.rs 2018-04-16 05:45:58 +03:00
Jordan Petridis
a9abd75b51
h-gtk: Nuke Action::UpdateSource.
Use inline glib::idle_add since it can be called on the spot.
2018-04-16 04:34:17 +03:00
Jordan Petridis
bc6eeec663
Replace if Let Err(_) with .map_err().ok() patterns.
I dislike the indentation and the noise if let adds.
2018-04-16 04:03:44 +03:00
Jordan Petridis
3132856efe
h-gtk/utils: Remove expects and unwraps on senders 2018-04-16 01:27:59 +03:00
Jordan Petridis
4db7628eed
h-gtk/utils: Make refresh_feed methods generic over Source. 2018-04-16 01:12:27 +03:00
Jordan Petridis
7b71f59d3e
ShowWidget: Make unsub button insensitive instead of hidding it. 2018-04-15 23:53:28 +03:00
Jordan Petridis
76c177bc0f
ShowWidget: Add a scrolled-window to the show description. 2018-04-15 04:07:43 +03:00
Jordan Petridis
50a508b596
Improve formatting 2018-04-15 02:50:06 +03:00
Jordan Petridis
59c634f626
Source: change the signature of the request constructor
We don't really need to return Self.
2018-04-15 02:39:24 +03:00
Jordan Petridis
08ebb9e7d6
pipeline: Make run function generic again.
also minor formatting changes.
2018-04-14 08:03:58 +03:00
Jordan Petridis
f5f0a5b873
Remove dead code. 2018-04-14 07:52:55 +03:00
Jordan Petridis
1036176e51
pipeline: Make sure that the futures will be run.
Use .then() combinator to override the result and return
Ok(()) even if the task fails. That allows us to use join_all
instead of the custom written collect_futures function.
2018-04-14 07:41:50 +03:00
Jordan Petridis
c6a24e839a
h-data: Implement a tail-recursion loop to follow redirects.
Follow http 301 permanent redirects by using a future::loop_fn.
It's kinda funcky, match_status still returns status_codes as erros
and a new DataError Variant had to be added to distiguise when we
should Loop::Continue. This could be cleaned up a lot.
2018-04-14 05:30:29 +03:00
Jordan Petridis
87421ce74d
Cargo fmt 2018-04-13 04:35:50 +03:00
Jordan Petridis
f94ccb9947
InAppNotification: Remove the need to pass a sender. 2018-04-13 03:46:32 +03:00
Jordan Petridis
1f1d4af8ba
Nuke the custom configure file.
There is nothing provided that meson can't do on it's own.
2018-04-13 01:54:35 +03:00
Jordan Petridis
633803e0ad
h-gtk: Fix views not updating after a download completes.
While ideally we want episode widget to determine their states
themselves and avoid refreshing the whole view, currently there
is no infrastructure for that which resulted in views not being
updated their EpisodeWidgets stuck in the InProggress state.
2018-04-12 06:54:08 +03:00
Jordan Petridis
74fb8dc75c
Update deps. 2018-04-12 05:06:33 +03:00
Jordan Petridis
2523a0cf90
Enable backtraces on the flatpak builds. 2018-04-12 02:49:51 +03:00
Jordan Petridis
47a58a9e65
Improve formatting 2018-04-12 02:49:27 +03:00
Jordan Petridis
27c4fd9b30
Remove .expect() call on channel that can be dropped. 2018-04-12 02:42:52 +03:00
Jordan Petridis
f3904c599b
Remove dead From implementations. 2018-04-12 02:17:23 +03:00
Jordan Petridis
b86f288e86
EpidoseWidget: Recalculate widget's state when cancel is clicked.
Previously we would refresh all the views when download/cancel
button was clicked. This was done mainly to avoid zombie widget bugs
that would arise from shared state.

Now we still refresh all the background views but not the visible one.
Instead the widget has the reponsibility of recalculating it's state.
2018-04-12 02:00:23 +03:00
Jordan Petridis
67bdd3664a
EpisodeWidget: Remove Widget::set/get name calls.
I don't even remember why this was there.
2018-04-12 00:13:43 +03:00
Jordan Petridis
8d4fdb8ece
EpidoseWidget: Only refresh background views when download is clicked. 2018-04-11 23:59:08 +03:00
Jordan Petridis
0720222423
h-gtk/app: use idle_add instead of timeout_add for updating on startup. 2018-04-10 21:07:07 +03:00
Merge Bot, Bors Wannabe
7bca841a1a Merge branch '8-lazy-episodes' into 'master'
Resolve "Lazy evaluate Podcasts and Episodes instead of loading everything in one go."

Closes #8

See merge request World/hammond!31
2018-04-10 17:08:21 +00:00
Jordan Petridis
572ab86bc4 Document utils::lazy_load. 2018-04-10 16:57:08 +00:00
Jordan Petridis
29cf5940f5 Lazy_load: move to utils module and make it public. 2018-04-10 16:57:08 +00:00
Jordan Petridis
4b4f5c39d4 Lazy_load: improve the naming scheme. 2018-04-10 16:57:08 +00:00
Jordan Petridis
5069430a3a Lazy_load: remove unnecessary clone of an Rc pointer. 2018-04-10 16:57:08 +00:00
Jordan Petridis
28d7373779 Lazy_load: Use IntoIterator for T, instead of Iterator. 2018-04-10 16:57:08 +00:00
Jordan Petridis
9d5fa04d49 Lazy_load: accept an iterator instead a Vec<_> over T. 2018-04-10 16:57:08 +00:00
Jordan Petridis
43bf8b3f15 Lazy_load: Avoid manually indexing.
make the data: Vec<T> mutable, then reverse the vector
so it can be used as a stack, and then use the ::pop()
method to retrieve the item.

This also avoid the constrain for Clone on T.
2018-04-10 16:57:08 +00:00
Jordan Petridis
ed80605755 Move the lazy_load logic to a Generic function. 2018-04-10 16:57:08 +00:00
Jordan Petridis
cc84a4637d EpisodesListBox: Do not block while fetching episode backlog. 2018-04-10 16:57:08 +00:00
Jordan Petridis
701b759ba2 EpisodesListBox: Add each widget lazyly. 2018-04-10 16:57:08 +00:00
Jordan Petridis
9cb2782ef9 ShowWidget: Initial Lazier evaluation of the widgets. 2018-04-10 16:57:08 +00:00
Jordan Petridis
5cd0a3c451
Fix the things I broke in b74dbb74bb
Someone really needs to restrict my access to anything that involves
transistors when I am sleep deprived.
2018-04-10 07:01:55 +03:00
Jordan Petridis
b74dbb74bb
h-data: Remove rel attributes from <a> tags when sanitizing html.
They are invalid in `pango` markup so theres no reason they
should are not needed. Also add some paranoid .trim() calls.
It returnes a &str slice so it's cheap.
2018-04-10 06:31:51 +03:00
210 changed files with 25803 additions and 9071 deletions

20
.gitignore vendored
View File

@ -1,9 +1,23 @@
target/
**/*.rs.bk
Cargo.lock
.vscode
*.ui~
resources.gresource
_build
_build/
build/
vendor/
.criterion
.criterion/
org.gnome.*.json~
podcasts-gtk/po/gnome-podcasts.pot
# scripts/test.sh
target_*/
# flatpak-builder stuff
.flatpak-builder/
app/
repo/
# Files configured by meson
podcasts-gtk/src/config.rs
podcasts-gtk/src/static_resource.rs

View File

@ -1,107 +1,30 @@
stages:
- test
- lint
.cargo_test_template: &cargo_test
stage: test
# variables:
# RUSTFLAGS: "-C link-dead-code"
# 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
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
rust:stable:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
rust:nightly:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
only:
- schedule
- web
include:
- project: 'gnome/citemplates'
file: 'flatpak/flatpak-ci-initiative-sdk-extensions.yml'
# ref: ''
flatpak:
image: registry.gitlab.com/alatiera/gnome-nightly-oci/rust-bundle:latest
stage: test
script:
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/alatiera/Hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.36'
variables:
MANIFEST_PATH: "org.gnome.Podcasts.Devel.json"
FLATPAK_MODULE: "gnome-podcasts"
MESON_ARGS: "-Dprofile=development"
APP_ID: "org.gnome.Podcasts.Devel"
RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo"
BUNDLE: "org.gnome.Podcasts.Devel.flatpak"
extends: '.flatpak'
# Build the flatpak repo
- flatpak-builder --run app org.gnome.Hammond.json meson --prefix=/app --libdir=/app/lib _build
- flatpak-builder --run app org.gnome.Hammond.json ninja -C _build install
- flatpak-builder --finish-only app org.gnome.Hammond.json
- flatpak build-export repo app
# Create a flatpak bundle
- flatpak build-bundle repo hammond-dev.flatpak 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:
- hammond-dev.flatpak
expire_in: 2 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/
# Configure and run rustfmt on nightly
# Configure and run rustfmt
# Exits and builds fails if on bad format
rustfmt:
image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:nightly"
stage: lint
image: "rust:slim"
stage: ".pre"
script:
- rustup component add rustfmt
# Create blank versions of our configured files
# so rustfmt does not yell about non-existent files or completely empty files
- echo -e "" >> podcasts-gtk/src/config.rs
- echo -e "" >> podcasts-gtk/src/static_resource.rs
- rustc -Vv && cargo -Vv
- cargo fmt --version
- cargo fmt --all -- --write-mode=diff
# Configure and run clippy on nightly
# Only fails on errors atm.
clippy:
image: "rustlang/rust:nightly"
stage: lint
script:
- rustc --version && cargo --version
- cargo install clippy --force
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all
when: manual
- cargo fmt --all -- --color=always --check

View File

@ -18,7 +18,7 @@ Some common cases might be:
Steps to reproduce:
1. Open Hammond
1. Open GNOME Podcasts
2. Do an action
3. ...

View File

@ -1,9 +1,40 @@
Detailed description of the issue. Put as much information as you can, potentially
with images showing the issue.
# Steps to reproduce
<!--
Explain in detail the steps on how the issue can be reproduced.
-->
1.
2.
3.
Steps to reproduce:
Reproducible in:
<!--
Please test if the issue was already fixed in the unstable version of the app.
For that, follow these steps:
1. Make sure Flatpak is installed or install it following these steps https://flatpak.org/setup
2. Install the unstable version of the app following, flatpak bundles can be found in the CI artifacts.
1. Open Hammond
2. Do an action
3. ...
If these steps failed, write in 'Other' the distribution youre using and
the version of the app.
-->
- Flatpak unstable: (yes or no) <!-- Write "yes" or "no" after the semicolon. -->
- Other:
# Current behavior
<!-- Describe the current behavior. -->
# Expected behavior
<!-- Describe the expected behavior. -->
# Additional information
<!--
Provide more information that could be relevant.
If the issue is a crash, provide a stack trace following the steps in:
https://wiki.gnome.org/Community/GettingInTouch/Bugzilla/GettingTraces
-->
<!-- Ignore the text under this line. -->
/label ~"Bug"

View File

@ -0,0 +1,41 @@
# 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"

View File

@ -1,17 +1,24 @@
Detailed description of the feature. Put as much information as you can.
### Use cases
<!--
Describe what problem(s) the user is experiencing and that this request
is trying to solve.
-->
Proposed Mockups:
(Add mockups of the proposed feature)
### Desired behavior
<!-- Describe the desired functionality. -->
## Design Tasks
* [ ] design tasks
### Benefits of the solution
<!-- List the possible benefits of the solution and how it fits in the project. -->
## Development Tasks
* [ ] development tasks
### Possible drawbacks
<!--
Describe possible drawbacks of the feature and list how it could affect
the project i.e. UI discoverability, complexity, impact in more or less
number of users, etc.
-->
## QA Tasks
* [ ] qa (quality assurance) tasks
<!-- Ignore the text under this line. -->
/label ~"Feature"

View File

@ -0,0 +1 @@
### Please attach a relevant issue to this MR, if this doesn't exist please create one.

View File

@ -6,52 +6,270 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
* Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/alatiera/Hammond/issues/7)
* Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/alatiera/Hammond/issues/25)
### Added:
### Changed:
### Fixed:
### Removed:
## [0.4.7] - 2019-10-23
### Added:
- Improved appdata validation and meson tests World/podcasts!89
- The ability to export show subscriptions to opml files World/podcasts!77
- Support for feeds requiring authentication World/podcasts!120
### Changed:
- Episodes now have a checkmark to show whether or not they've been played World/podcasts!106
- Changed to how errors are shown when adding podcasts World/podcasts!108 World/podcasts!109 World/podcasts!110
- Improved integration of cargo and meson World/podcasts!94
- Refactored some macros for error handling World/podcasts!82
- Refactored the handling of styling changes World/podcasts!119
- Updated the icon to better match the HIG guidlines World/podcasts#102
- Made Podcasts use a GtkApplication subclass World/podcasts!113
- Updated the MPRIS permissions in order to remove a sandbox hole World/podcasts!124
- Bumped gtk and libhandy minimum versions
### Fixed:
- Rewind now works regardless if its the start or the end of the episode World/podcasts!83
- Typos in the README and CONTRIBUTING docs World/podcast!97 World/podcast!98 World/podcast!99 World/podcasts!121
- Show cover is reset properly now if there isn't an image World/podcasts#114
- Query pairs are no longer stripped from URLs World/podcasts!111
- Pause MPRIS button now works on KDE Plasma World/podcasts#115
- The playback widget now properly reflects the playback state on episode change World/podcasts!116
### Removed:
- All preferences World/podcast!104
## [0.4.6] - 2018-10-07
### Added:
- Felix, @haecker-felix, wrote an [mpris crate](https://crates.io/crates/mpris-player) and implemented MPRIS2 client side support! !74 #68
### Changed:
- Download Cancel button was changed to an Icon instead of a label !72
- The applciation will no longer scale below 360p in width 1933c79f7a87d8261d91ca4e14eb51c1ddc66624
- Update to the latest HIG 5050dda4d2f75b706842de8507d115dd5a1bd0a9
- Chris, @brainblasted, upgraded hyper to 0.12, this brings openssl 1.1 support !75
- Pipeline backend is now completly migrated to tokio-runtime 0887789f5e653dd92ad397fb39561df6dffcb45c
- Resume playing an episode will attempt to rewind the track only if more than a minute has passed since the last pause !76
### Fixed:
- Fixed a regression where indexing feeds was blocking the `tokio reactor` #88 !70
- Episodeds Listbox no longer resizes when a download starts #89 !72
- The `total_size` label of the `EpisodeWidget` now behaves correctly if the request fails #90 !73
- The Pipeline will no longer log things in stderr for Requests that returned 304 and are expected to be skipped da361d0cb93cd8edd076859b2c607509a96dac8d
- A bug where the HomeView wold get into an invalid state if your only shows had no episodes 32bd2a89a34e8e940b3b260c6be76defe11835ed
### Translations:
**Added**
- Brazilian Portuguese translation 586cf16f
- Swedish translation 2e527250
- Italian translation a23297e5
- Friulian translation 60e09c0d
- Hungarian translation 2751a828
- Croatian translation 0476b67b
- Latvian translation a681b2c9
- Czech translation 3563a964
- Catalan translation 6ea3fc91
**Updated**
- German translation
- Finnish translation
- Polish translation
- Turkish translation
- Croatian translation
- Indonesian translation
- Spanish translation
## [0.4.5] - 2018-08-31
### Added:
- [OARS](https://hughsie.github.io/oars/) Tags where added for compatibility with Store clients b0c94dd9
- Daniel added support for Translations !46
- Svitozar Cherepii(@svito) created a [wiki page](https://wiki.gnome.org/Apps/Podcasts) 70e79e50
- Libhandy was added as a dependancy #70
- Development builds can now be installed in parallel with stable builds !64
### Changed:
- The update indication was moved to an In-App notification #72
- The app icon's accent color was changed from orange to red 0dfb4859
- The stack switcher in the Headerbar is now insesitive on Empty Views !63
### Fixed:
- Improved handling of HTTP redirections #64 !61 !62
- Fixed a major performance regression when loading show covers !67
- More refference cycles have been fixed !59
- OPML import dialog now exits properly and no longer keeps the application from shuting down !65
- Update action is disabled if there isn't something to update #71
### Translations:
- Added Finish 93696026
- Added Polish 1bd6efc0
- Added Turkish 73929f2d
- Added Spanish !46
- Added German 6b6c390c
- Added Galician 0060a634
- Added Indonesian ded0224f
- Added Korean 36f16963
## [0.4.4] - 2018-07-31
### Changed:
- `SendCell` crate was replaced with `Fragile`. (Jorda Petridis) 838320785ebbea94e009698b473495cfec076f54
- Update dependancies (Jorda Petridis) 91bea8551998b16e44e5358fdd43c53422bcc6f3
### Fixed:
- Fix more refference cycles. (Jorda Petridis) 3496df24f8d8bfa8c8a53d8f00262d42ee39b41c
- Actually fix cargo-vendor (Jorda Petridis)
## [0.4.3] - 2018-07-27
### Fixed:
- Fix the cargo vendor config for the tarball releash script. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
## [0.4.2] - 2018-07-27
### Changed:
- Minimum size requested by the Views. (Jorda Petridis) 7c96152f3f53f271247230dccf1c9cd5947b685f
### Fixed:
- Screenshot metadata in appstream data. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
## [0.4.1] - 2018-07-26
### Added:
- Custom icons for the fast-forward and rewind actions in the Player were added. (Tobias Bernard) e77000076b3d78b8625f4c7ef367376d0130ece6
- Hicolor and symbolic icons for the Application. (Tobias Bernard and Sam Hewitt) edae1b04801dba9d91d5d4145db79b287f0eec2c
- Basic prefferences dialog (Zander Brown). [34](https://gitlab.gnome.org/World/podcasts/merge_requests/34)
- Dbus service preperation. Not used till the MPRIS2 integration has landed. (Zander Brown) [42](https://gitlab.gnome.org/World/podcasts/merge_requests/42)
- Episodes and Images will only get drawn when needed. Big Performance impact. (Jordan Petridis) [43](https://gitlab.gnome.org/World/podcasts/merge_requests/43)
### Changed:
- The `ShowWidget` control button were moved to a secondary menu in the Headerbar. (Jordan Petridis) 536805791e336a3e112799be554706bb804d2bef
- EmptyView layout improvements. (Jorda Petridis) 3c3d6c1e7f15b88308a9054b15a6ca0d8fa233ce 518ea9c8b57885c44bda9c418b19fef26ae0e55d
- Improved the `AddButton` behavior. (Jorda Petridis) 67ab54f8203f19aad198dc49e935127d25432b41
### Fixed:
- A couple reffence cycles where fixed. (Jorda Petridis)
### Removed:
- The delay between the application startup and the `update_on_startup` action. (Jorda Petridis) 7569465a612ee5ef84d0e58f4e1010c8d14080d4
## [0.4.0] - 2018-07-04
### Added:
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
### Changed:
- The `FileChooser` of the OPML import was changed to use the `FileChooserNative` widget/API. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
- The `EpisdeWidget` was refactored.
[!38](https://gitlab.gnome.org/World/podcasts/merge_requests/38)
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
- An embeded audio player was implemented!
[!40](https://gitlab.gnome.org/World/podcasts/merge_requests/40)
- Various Database changes.
[!41](https://gitlab.gnome.org/World/podcasts/merge_requests/41)
### Fixed:
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/podcasts/merge_requests/37)
## [0.3.4] - 2018-05-20
### Fixed:
- Flatpak can now access the Home folder. This fixes the OPML import feature from
not being able to access any file.
## [0.3.3] - 2018-05-19
### Added:
- Initial functionality for importing shows from an OPML file was implemented.
- 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/podcasts/issues/7)
- Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/World/podcasts/issues/25)
- `Rayon` Threadpools are now used instead of unlimited one-off threads.
- `EpisdeWidget`s are now loaded asynchronously accross views.
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
- `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/podcasts/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/podcasts/issues/47)
- Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/World/podcasts/issues/49)
- Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/World/podcasts/issues/50)
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/World/podcasts/merge_requests/22) [!23](https://gitlab.gnome.org/World/podcasts/merge_requests/23)
- Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/World/podcasts/issues/44)
* Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/alatiera/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/alatiera/Hammond/issues/49)
* EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/18)
* Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/alatiera/Hammond/issues/35)
* Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/alatiera/Hammond/issues/52)
* Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/alatiera/Hammond/issues/53)
* Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/alatiera/Hammond/issues/50)
* Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/22) [!23](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/23)
* Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/alatiera/Hammond/issues/44)
### Changed:
- EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/World/podcasts/merge_requests/18)
- Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/World/podcasts/issues/35)
- Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/World/podcasts/issues/53)
### Fixed:
- Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/World/podcasts/issues/52)
## [0.3.0] - 2018-02-11
* Tobias Bernard Redesigned the whole Gtk+ client.
* Complete re-write of hammond-data and hammond-gtk modules.
* Error handling for all crates was migrated from error-chain to Failure.
* Hammond-data now uses futures to parse feeds.
* Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
- Tobias Bernard Redesigned the whole Gtk+ client.
- Complete re-write of hammond-data and hammond-gtk modules.
- Error handling for all crates was migrated from error-chain to Failure.
- Hammond-data now uses futures to parse feeds.
- Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
## [0.2.0] - 2017-11-28
* Database Schema Breaking Changes.
* Added url sanitization. #4.
* Reworked and refactored of the hammond-data API.
* Added some more unit tests
* Documented hammond-data public API.
- Database Schema Breaking Changes.
- Added url sanitization. #4.
- Reworked and refactored of the hammond-data API.
- Added some more unit tests
- Documented hammond-data public API.
## [0.1.1] - 2017-11-13
* Added appdata.xml file
- Added appdata.xml file
## [0.1.0] - 2017-11-13
Initial Release
- Initial Release

View File

@ -1,18 +1,18 @@
## Contributing to Hammond
## Contributing to GNOME Podcasts
Thank you for looking in this file!
When contributing to the development of Hammond, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
When contributing to the development of GNOME Podcasts, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
If you have any questions regarding the use or development of GNOME Podcasts,
want to discuss design or simply hang out, please join us in [#gnome-podcasts:matrix.org](https://matrix.to/#/#gnome-podcasts:matrix.org) or [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOfConduc), please follow it in all your interactions with the project.
Please note we have a [code of conduct](/code-of-conduct.md), please follow it in all your interactions with the project.
## Source repository
Hammond's main source repository is at gitlab.gnome.org. You can view
the web interface [here](https://gitlab.gnome.org/alatiera/hammond)
GNOME Podcasts's main source repository is at gitlab.gnome.org. You can view
the web interface [here](https://gitlab.gnome.org/World/podcasts)
Development happens in the master branch.
@ -26,9 +26,12 @@ makes things easier for the maintainers.
We use [rustfmt](https://github.com/rust-lang-nursery/rustfmt) for code formatting and we enforce it on the gitlab-CI server.
Quick setup
***Installing rustfmt*** As of 2019/Jan, our continuous integration
pipeline assumes the version of rustfmt that is distributed through the
stable channel of [rustup](rustup.rs). You can install it with
```
cargo install rustfmt-nightly
rustup component add rustfmt
cargo fmt --all
```
@ -36,7 +39,7 @@ It is recommended to add a pre-commit hook to run cargo test and `cargo fmt`.
Don't forget to `git add` again after `cargo fmt`.
```
#!/bin/sh
cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
cargo test -- --test-threads=1 && cargo fmt --all -- --check
```
## Running the test suite
@ -44,14 +47,14 @@ cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
Running the tests requires an internet connection and it it will download some files from the [Internet Archive](archive.org)
The test suite sets a temporary sqlite database in the `/tmp` folder.
Due to that it's not possible to run them in parrallel.
Due to that it's not possible to run them in parallel.
In order to run the test suite use the following: `cargo test -- --test-threads=1`
# Issues, issues and more issues!
There are many ways you can contribute to Hammond, and all of them involve creating issues
in [Hammond issue tracker](https://gitlab.gnome.org/alatiera/Hammond/issues). This is the entry point for your contribution.
There are many ways you can contribute to GNOME Podcasts, and all of them involve creating issues
in [GNOME Podcasts issue tracker](https://gitlab.gnome.org/World/podcasts/issues). This is the entry point for your contribution.
To create an effective and high quality ticket, try to put the following information on your
ticket:
@ -74,7 +77,7 @@ If it's an issue, add the steps to reproduce like this:
Steps to reproduce:
1. Open Hammond
1. Open GNOME Podcasts
2. Do an Action
3. ...
@ -91,13 +94,13 @@ Steps to reproduce:
* [ ] qa (quality assurance) tasks
```
## Pull Request Process
## Merge Request Process
1. Ensure your code compiles. Run `make` before creating the pull request.
1. Ensure your code compiles. Run `meson` & `ninja` before creating the merge request.
2. Ensure the test suit passes. Run `cargo test -- --test-threads=1`.
3. Ensure your code is properly formated. Run `cargo fmt --all`.
3. Ensure your code is properly formatted. Run `cargo fmt --all`.
4. If you're adding new API, it must be properly documented.
5. The commit message is formatted as follows:
5. The commit message has to be formatted as follows:
```
component: <summary>
@ -107,8 +110,8 @@ Steps to reproduce:
<link to the bug ticket>
```
6. You may merge the pull request in once you have the sign-off of the maintainers, or if you
6. You may merge the merge request once you have the sign-off of the maintainers, or if you
do not have permission to do that, you may request the second reviewer to merge it for you.
## Code of Conduct
We follow the Gnome [Code of Conduct.](https://wiki.gnome.org/Foundation/CodeOfConduct)
We follow the [GNOME Foundation Code of Conduct](/code-of-conduct.md).

3330
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,9 @@
[workspace]
members = [
"hammond-data",
"hammond-downloader",
"hammond-gtk"
"podcasts-data",
"podcasts-downloader",
"podcasts-gtk"
]
[profile.release]
debug = false
debug = true

View File

@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Hammond
GNOME Podcasts
Copyright (C) 2017 Jordan Petridis
This program is free software: you can redistribute it and/or modify
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Hammond Copyright (C) 2017 Jordan Petridis
GNOME Podcasts Copyright (C) 2017 Jordan Petridis
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.

153
README.md
View File

@ -1,111 +1,97 @@
# Hammond
# GNOME Podcasts
## A Podcast Client for the GNOME Desktop written in Rust.
### A Podcast application for GNOME.
Listen to your favorite podcasts, right from your desktop.
[![pipeline status](https://gitlab.gnome.org/alatiera/Hammond/badges/master/pipeline.svg)](https://gitlab.gnome.org/alatiera/Hammond/commits/master)
[![Dependency Status](https://dependencyci.com/github/alatiera/Hammond/badge)](https://dependencyci.com/github/alatiera/Hammond)
### Features
* TBA
![episdes_view](./screenshots/episodes_view.png)
![episdes_view](./screenshots/home_view.png)
![shows_view](./screenshots/shows_view.png)
![show_widget](./screenshots/show_widget.png)
## Available on Flathub
[![Get it from Flathub!](https://flathub.org/assets/badges/flathub-badge-en.svg)](https://flathub.org/apps/details/org.gnome.Podcasts)
## Quick start
Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) >= 3.28.
GNOME Podcasts can be built and run with [Gnome Builder][builder] >= 3.28.
Just clone the repo and hit the run button!
Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
You can get Builder from [here][get_builder].
## Broken Feeds
Found a feed that does not work in Hammond?
Please [open an issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
Found a feed that does not work in GNOME Podcasts?
Please [open an issue][new_issue] and choose the `BrokenFeed` template so we will know and fix it!
## Getting in Touch
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
Note:
There isn't much documentation yet, so you will probably have question about parts of the Code.
If you have any questions regarding the use or development of GNOME Podcasts,
want to discuss design or simply hang out, please join us on our [irc][irc] or [matrix][matrix] channel.
## Building
### Flatpak
Flatpak is the reccomended way of building and installing Hammond.
#### Building a Flatpak
Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
```bash
# Add flathub repo
flatpak --user remote-add flathub --if-not-exists https://dl.flathub.org/repo/flathub.flatpakrepo
# Add the gnome-nightly repo
flatpak --user remote-add gnome-nightly --if-not-exists https://sdk.gnome.org/gnome-nightly.flatpakrepo
# Install the gnome-nightly Sdk and Platform runtim
flatpak --user install gnome-nightly org.gnome.Sdk org.gnome.Platform
# Install the required rust-stable extension from flathub
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
flatpak build-bundle repo hammond org.gnome.Hammond
```
### Building from soure
Flatpak is the recommended way of building and installing GNOME Podcasts.
Here are the dependencies you will need.
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
cd hammond/
./configure --prefix=/usr/local
make && sudo make install
# Add flathub and the gnome-nightly repo
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists gnome-nightly https://nightly.gnome.org/gnome-nightly.flatpakrepo
# Install the gnome-nightly Sdk and Platform runtime
flatpak install --user gnome-nightly org.gnome.Sdk org.gnome.Platform
# Install the required rust-stable extension from flathub
flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//19.08
```
**Additional:**
To install the resulting flatpak you can do:
You can run `sudo make uninstall` for removal
```bash
flatpak-builder --user --install --force-clean --repo=repo podcasts org.gnome.Podcasts.json
```
### Building from source
```sh
git clone https://gitlab.gnome.org/World/podcasts.git
cd gnome-podcasts/
meson --prefix=/usr build
ninja -C build
sudo ninja -C build install
```
#### Dependencies
* Rust stable 1.22 or later along with cargo.
* Gtk+ 3.22 or later
* Rust stable 1.34 or later along with cargo.
* Gtk+ 3.24.11 or later
* Gstreamer 1.16 or later
* libhandy 0.0.11 or later
* Meson
* A network connection
Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first
**Debian/Ubuntu**
```sh
apt-get update -yqq
apt-get install -yqq --no-install-recommends build-essential
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
```
**Fedora**
```sh
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
```
If you happen to build it on other distributions please let me know the names
of the corresponding libraries. Feel free to open a MR or an Issue to note it.
## Contributing
There alot of thins yet to be done.
There are a lot of things yet to be done.
If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines].
You can start by taking a look at [Issues](https://gitlab.gnome.org/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][issues] or by opening a [New issue][new_issue].
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
[contribution-guidelines]: https://gitlab.gnome.org/alatiera/Hammond/blob/master/CONTRIBUTING.md
[contribution-guidelines]: https://gitlab.gnome.org/World/podcasts/blob/master/CONTRIBUTING.md
### Translations
Translation of this project takes place on the GNOME translation platform,
[Damned Lies](https://l10n.gnome.org/module/podcasts). For further
information on how to join a language team, or even to create one, please see
[GNOME Translation Project wiki page](https://wiki.gnome.org/TranslationProject).
## Overview
@ -113,34 +99,45 @@ There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source c
```sh
$ tree -d
├── screenshots # png's used in the README.md
├── hammond-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
├── podcasts-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
│   ├── migrations # Diesel SQL migrations.
│   │   └── ...
│   ├── src
│   └── tests
│   └── feeds # Raw RSS Feeds used for tests.
├── hammond-downloader # Really basic, Really crappy downloader.
├── podcasts-downloader # Really basic, Really crappy downloader.
│   └── src
├── hammond-gtk # The Gtk+ Client
├── podcasts-gtk # The Gtk+ Client
│   ├── resources # GResources folder
│   │   └── gtk # Contains the glade.ui files.
│   └── src
│   ├── 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.
```
## A note about the project's name
The project was named after Allan Moore's character [Evey Hammond](https://en.wikipedia.org/wiki/Evey_Hammond) from the graphic novel V for Vendetta.
It has nothing to do with the horrible headlines on the news.
The project used to be called Hammond, after Allan Moore's character [Evey Hammond][hammond] from the graphic novel V for Vendetta.
It was renamed to GNOME Podcasts on 2018/07/24 shortly before its first public release.
## Acknowledgments
Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Design/Apps/Music) and [Vocal](http://vocalproject.net/).
GNOME Podcasts's design is heavily inspired by [GNOME Music][music] and [Vocal][vocal].
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
We also copied some elements from [GNOME News][news].
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
And almost the entirety of the build system is copied from the [Fractal][fractal] project.
[vendor]: https://github.com/alexcrichton/cargo-vendor
[irc]: irc://irc.gnome.org/#hammond
[matrix]: https://matrix.to/#/#gnome-podcasts:matrix.org
[flatpak_setup]: https://flatpak.org/setup/
[music]: https://wiki.gnome.org/Design/Apps/Music
[vocal]: http://vocalproject.net/
[news]: https://wiki.gnome.org/Design/Apps/Potential/News
[fractal]: https://gitlab.gnome.org/World/fractal
[hammond]: https://en.wikipedia.org/wiki/Evey_Hammond
[issues]: https://gitlab.gnome.org/World/podcasts/issues
[new_issue]: https://gitlab.gnome.org/World/podcasts/issues/new
[builder]: https://wiki.gnome.org/Apps/Builder
[get_builder]: https://wiki.gnome.org/Apps/Builder/Downloads

11
TODO.md
View File

@ -4,12 +4,11 @@
## Priorities
- [ ] Unplayed Only and Downloaded only view.
- [ ] OPML import/export // Probably need to create a crate.
## Second
- [ ] Make use of file metadas, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
- [ ] Make use of file metadas?, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
- [ ] Episode queue
- [ ] Embedded player
- [ ] MPRIS integration
@ -19,14 +18,8 @@
- [ ] Download Queue
- [ ] Ability to Stream content on demand
- [ ] soundcloud and itunes feeds // [This](http://getrssfeed.com) seems intresting.
- [ ] rss feeds from soundcloud urls? // [This](http://getrssfeed.com) seems intresting.
- [ ] Integrate with Itunes API for various crap?
- [ ] YoutubeFeeds?
## Rest Tasks
**Would be nice:**
- [ ] Make Podcast cover fetchng and loading not block the execution of the program at startup.
- [ ] Lazy evaluate episode loading based on the show_widget's scrolling.

126
code-of-conduct.md Normal file
View File

@ -0,0 +1,126 @@
# GNOME Code of Conduct
Thank you for being a part of the GNOME project. We value your participation and want everyone to have an enjoyable and fulfilling experience. Accordingly, all participants are expected to follow this Code of Conduct, and to show respect, understanding, and consideration to one another. Thank you for helping make this a welcoming, friendly community for everyone.
## Scope
This Code of Conduct applies to all online GNOME community spaces, including, but not limited to:
* Issue tracking systems - bugzilla.gnome.org
* Documentation and tutorials - developer.gnome.org
* Code repositories - git.gnome.org and gitlab.gnome.org
* Mailing lists - mail.gnome.org
* Wikis - wiki.gnome.org
* Chat and forums - irc.gnome.org, discourse.gnome.org, GNOME Telegram channels, and GNOME groups and channels on Matrix.org (including bridges to GNOME IRC channels)
* Community spaces hosted on gnome.org infrastructure
* Any other channels or groups which exist in order to discuss GNOME project activities
Communication channels and private conversations that are normally out of scope may be considered in scope if a GNOME participant is being stalked or harassed. Social media conversations may be considered in-scope if the incident occurred under a GNOME event hashtag, or when an official GNOME account on social media is tagged, or within any other discussion about GNOME. The GNOME Foundation reserves the right to take actions against behaviors that happen in any context, if they are deemed to be relevant to the GNOME project and its participants.
All participants in GNOME online community spaces are subject to the Code of Conduct. This includes GNOME Foundation board members, corporate sponsors, and paid employees. This also includes volunteers, maintainers, leaders, contributors, contribution reviewers, issue reporters, GNOME users, and anyone participating in discussion in GNOME online spaces.
## Reporting an Incident
If you believe that someone is violating the Code of Conduct, or have
any other concerns, please [contact the Code of Conduct committee](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide).
## Our Standards
The GNOME online community is dedicated to providing a positive experience for everyone, regardless of:
* age
* body size
* caste
* citizenship
* disability
* education
* ethnicity
* familial status
* gender expression
* gender identity
* genetic information
* immigration status
* level of experience
* nationality
* personal appearance
* pregnancy
* race
* religion
* sex characteristics
* sexual orientation
* sexual identity
* socio-economic status
* tribe
* veteran status
### Community Guidelines
Examples of behavior that contributes to creating a positive environment include:
* **Be friendly.** Use welcoming and inclusive language.
* **Be empathetic.** Be respectful of differing viewpoints and experiences.
* **Be respectful.** When we disagree, we do so in a polite and constructive manner.
* **Be considerate.** Remember that decisions are often a difficult choice between competing priorities. Focus on what is best for the community. Keep discussions around technology choices constructive and respectful.
* **Be patient and generous.** If someone asks for help it is because they need it. When documentation is available that answers the question, politely point them to it. If the question is off-topic, suggest a more appropriate online space to seek help.
* **Try to be concise.** Read the discussion before commenting in order to not repeat a point that has been made.
### Inappropriate Behavior
Community members asked to stop any inappropriate behavior are expected to comply immediately.
We want all participants in the GNOME community have the best possible experience they can. In order to be clear what that means, we've provided a list of examples of behaviors that are inappropriate for GNOME community spaces:
* **Deliberate intimidation, stalking, or following.**
* **Sustained disruption of online discussion, talks, or other events.** Sustained disruption of events, online discussions, or meetings, including talks and presentations, will not be tolerated. This includes 'Talking over' or 'heckling' event speakers or influencing crowd actions that cause hostility in event sessions. Sustained disruption also includes drinking alcohol to excess or using recreational drugs to excess, or pushing others to do so.
* **Harassment of people who don't drink alcohol.** We do not tolerate derogatory comments about those who abstain from alcohol or other substances. We do not tolerate pushing people to drink, talking about their abstinence or preferences to others, or pressuring them to drink - physically or through jeering.
* **Sexist, racist, homophobic, transphobic, ableist language or otherwise exclusionary language.** This includes deliberately referring to someone by a gender that they do not identify with, and/or questioning the legitimacy of an individual's gender identity. If you're unsure if a word is derogatory, don't use it. This also includes repeated subtle and/or indirect discrimination.
* **Unwelcome sexual attention or behavior that contributes to a sexualized environment.** This includes sexualized comments, jokes or imagery in interactions, communications or presentation materials, as well as inappropriate touching, groping, or sexual advances. Sponsors should not use sexualized images, activities, or other material. Meetup organizing staff and other volunteer organizers should not use sexualized clothing/uniforms/costumes, or otherwise create a sexualized environment.
* **Unwelcome physical contact.** This includes touching a person without permission, including sensitive areas such as their hair, pregnant stomach, mobility device (wheelchair, scooter, etc) or tattoos. This also includes physically blocking or intimidating another person. Physical contact or simulated physical contact (such as emojis like "kiss") without affirmative consent is not acceptable. This includes sharing or distribution of sexualized images or text.
* **Violence or threats of violence.** Violence and threats of violence are not acceptable - online or offline. This includes incitement of violence toward any individual, including encouraging a person to commit self-harm. This also includes posting or threatening to post other people's personally identifying information ("doxxing") online.
* **Influencing or encouraging inappropriate behavior.** If you influence or encourage another person to violate the Code of Conduct, you may face the same consequences as if you had violated the Code of Conduct.
* **Possession of an offensive weapon at a GNOME event.** This includes anything deemed to be a weapon by the event organizers.
The GNOME community prioritizes marginalized people's safety over privileged people's comfort. The committee will not act on complaints regarding:
* "Reverse"-isms, including "reverse racism," "reverse sexism," and "cisphobia"
* Reasonable communication of boundaries, such as "leave me alone," "go away," or "I'm not discussing this with you."
* Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions
* Communicating boundaries or criticizing oppressive behavior in a "tone" you don't find congenial
The examples listed above are not against the Code of Conduct. If you have questions about the above statements, please [read this document](https://github.com/sagesharp/code-of-conduct-template/blob/master/code-of-conduct/example-reversisms.md#supporting-diversity).
If a participant engages in behavior that violates this code of conduct, the GNOME Code of Conduct committee may take any action they deem appropriate. Examples of consequences are outlined in the [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures).
## Procedure for Handling Incidents
* [Reporter Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide)
* [Moderator Procedures](https://wiki.gnome.org/Foundation/CodeOfConduct/ModeratorProcedures)
* [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures)
## License
The GNOME Online Code of Conduct is licensed under a [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/)
![Creative Commons License](http://i.creativecommons.org/l/by-sa/3.0/88x31.png)
## Attribution
The GNOME Online Code of Conduct was forked from the example policy from the [Geek Feminism wiki, created by the Ada Initiative and other volunteers](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), which is under a Creative Commons Zero license.
Additional language was incorporated and modified from the following Codes of Conduct:
* [Citizen Code of Conduct](http://citizencodeofconduct.org/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/).
* [Code of Conduct template](https://github.com/sagesharp/code-of-conduct-template/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/) by [Otter Tech](https://otter.technology/code-of-conduct-training)
* [Contributor Covenant version 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct) (licensed [CC BY 4.0](https://github.com/ContributorCovenant/contributor_covenant/blob/master/LICENSE.md))
* [Data Carpentry Code of Conduct](https://docs.carpentries.org/topic_folders/policies/index_coc.html) is licensed [Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/)
* [Django Project Code of Conduct](https://www.djangoproject.com/conduct/) is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/)
* [Fedora Code of Conduct](http://fedoraproject.org/code-of-conduct)
* [Geek Feminism Anti-harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy) which is under a [Creative Commons Zero license](https://creativecommons.org/publicdomain/zero/1.0/)
* [Previous GNOME Foundation Code of Conduct](https://wiki.gnome.org/action/recall/Foundation/CodeOfConduct/Old)
* [LGBTQ in Technology Slack Code of Conduct](https://lgbtq.technology/coc.html) licensed [Creative Commons Zero](https://creativecommons.org/publicdomain/zero/1.0/)
* [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) is licensed [Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/).
* [Python Mentors Code of Conduct](http://pythonmentors.com/)
* [Speak Up! Community Code of Conduct](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html), licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/)

186
configure vendored
View File

@ -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}

View File

@ -1,44 +0,0 @@
[package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
name = "hammond-data"
version = "0.1.0"
workspace = "../"
[dependencies]
ammonia = "1.1.0"
chrono = "0.4.1"
derive_builder = "0.5.1"
itertools = "0.7.8"
lazy_static = "1.0.0"
log = "0.4.1"
rayon = "1.0.1"
rfc822_sanitizer = "0.3.3"
rss = "1.4.0"
url = "1.7.0"
xdg = "2.1.0"
futures = "0.1.21"
hyper = "0.11.24"
tokio-core = "0.1.16"
hyper-tls = "0.1.3"
native-tls = "0.1.5"
num_cpus = "1.8.0"
failure = "0.1.1"
failure_derive = "0.1.1"
[dependencies.diesel]
features = ["sqlite", "r2d2"]
version = "1.1.1"
[dependencies.diesel_migrations]
features = ["sqlite"]
version = "1.1.0"
[dev-dependencies]
rand = "0.4.2"
tempdir = "0.3.7"
criterion = "0.2.2"
pretty_assertions = "0.5.1"
[[bench]]
name = "bench"
harness = false

View File

@ -1,123 +0,0 @@
#[macro_use]
extern crate criterion;
use criterion::Criterion;
// extern crate futures;
extern crate hammond_data;
extern crate hyper;
extern crate hyper_tls;
extern crate rand;
extern crate tokio_core;
// extern crate rayon;
extern crate rss;
// use rayon::prelude::*;
// use futures::future::*;
use tokio_core::reactor::Core;
use hammond_data::FeedBuilder;
use hammond_data::Source;
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
// use hammond_data::errors::*;
use std::io::BufReader;
// RSS feeds
const INTERCEPTED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-Intercepted.xml");
const INTERCEPTED_URL: &str = "https://web.archive.org/web/20180120083840if_/https://feeds.\
feedburner.com/InterceptedWithJeremyScahill";
const UNPLUGGED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-LinuxUnplugged.xml");
const UNPLUGGED_URL: &str =
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged";
const TIPOFF: &[u8] = include_bytes!("../tests/feeds/2018-01-20-TheTipOff.xml");
const TIPOFF_URL: &str =
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// This feed has HUGE descripion and summary fields which can be very
// very expensive to parse.
const CODE: &[u8] = include_bytes!("../tests/feeds/2018-01-20-GreaterThanCode.xml");
const CODE_URL: &str =
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast";
// Relative small feed
const STARS: &[u8] = include_bytes!("../tests/feeds/2018-01-20-StealTheStars.xml");
const STARS_URL: &str =
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
static FEEDS: &[(&[u8], &str)] = &[
(INTERCEPTED, INTERCEPTED_URL),
(UNPLUGGED, UNPLUGGED_URL),
(TIPOFF, TIPOFF_URL),
(CODE, CODE_URL),
(STARS, STARS_URL),
];
// 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) {
truncate_db().unwrap();
let url = "https://www.greaterthancode.com/feed/podcast";
let mut core = Core::new().unwrap();
c.bench_function("index_large_feed", move |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(CODE)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
});
truncate_db().unwrap();
}
fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://rss.art19.com/steal-the-stars";
let mut core = Core::new().unwrap();
c.bench_function("index_small_feed", move |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(STARS)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
});
truncate_db().unwrap();
}
criterion_group!(
benches,
bench_pipeline,
bench_index_large_feed,
bench_index_small_feed
);
criterion_main!(benches);

View File

@ -1,392 +0,0 @@
//! Random CRUD helper functions.
use chrono::prelude::*;
use diesel::prelude::*;
use diesel;
use diesel::dsl::exists;
use diesel::select;
use database::connection;
use errors::DataError;
use models::*;
pub fn get_sources() -> Result<Vec<Source>, DataError> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.order((http_etag.asc(), last_modified.asc()))
.load::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.order(title.asc())
.load::<Podcast>(&con)
.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_any(filter_ids))
.load::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((rowid, local_uri, played))
.filter(local_uri.is_not_null())
.load::<EpisodeCleanerQuery>(&con)
.map_err(From::from)
}
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> {
// use schema::episode::dsl::*;
// let db = connection();
// let con = db.get()?;
// episode
// .filter(played.is_not_null())
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((rowid, local_uri, played))
.filter(played.is_not_null())
.load::<EpisodeCleanerQuery>(&con)
.map_err(From::from)
}
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.filter(rowid.eq(ep_id))
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.filter(rowid.eq(ep_id))
.select(local_uri)
.get_result::<Option<String>>(&con)
.map_err(From::from)
}
pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32],
limit: u32,
) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode;
let db = connection();
let con = db.get()?;
episode::table
.select((
episode::rowid,
episode::title,
episode::uri,
episode::local_uri,
episode::epoch,
episode::length,
episode::duration,
episode::played,
episode::podcast_id,
))
.order(episode::epoch.desc())
.filter(episode::podcast_id.ne_any(filter_ids))
.limit(i64::from(limit))
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_id(pid: i32) -> Result<Podcast, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.filter(id.eq(pid))
.get_result::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_podcast_cover_from_id(pid: i32) -> Result<PodcastCoverQuery, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.select((id, title, image_uri))
.filter(id.eq(pid))
.get_result::<PodcastCoverQuery>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Episode::belonging_to(parent)
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<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(podcast_id.eq(parent.id()))
// .group_by(epoch)
.order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Episode::belonging_to(parent)
.filter(played.is_null())
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
// pub(crate) fn get_pd_episodes_limit(parent: &Podcast, limit: u32) ->
// Result<Vec<Episode>, DataError> { use schema::episode::dsl::*;
// let db = connection();
// let con = db.get()?;
// Episode::belonging_to(parent)
// .order(epoch.desc())
// .limit(i64::from(limit))
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub fn get_source_from_uri(uri_: &str) -> Result<Source, DataError> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.filter(uri.eq(uri_))
.get_result::<Source>(&con)
.map_err(From::from)
}
pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.filter(id.eq(id_))
.get_result::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_source_id(sid: i32) -> Result<Podcast, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.filter(source_id.eq(sid))
.get_result::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub(crate) fn get_episode_minimal_from_pk(
title_: &str,
pid: i32,
) -> Result<EpisodeMinimal, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((rowid, title, uri, epoch, duration, guid, podcast_id))
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.get_result::<EpisodeMinimal>(&con)
.map_err(From::from)
}
pub(crate) fn remove_feed(pd: &Podcast) -> Result<(), DataError> {
let db = connection();
let con = db.get()?;
con.transaction(|| {
delete_source(&con, pd.source_id())?;
delete_podcast(&con, pd.id())?;
delete_podcast_episodes(&con, pd.id())?;
info!("Feed removed from the Database.");
Ok(())
})
}
fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
use schema::source::dsl::*;
diesel::delete(source.filter(id.eq(source_id))).execute(con)
}
fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
use schema::podcast::dsl::*;
diesel::delete(podcast.filter(id.eq(podcast_id))).execute(con)
}
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
use schema::episode::dsl::*;
diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(con)
}
pub fn source_exists(url: &str) -> Result<bool, DataError> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(source.filter(uri.eq(url))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(podcast.filter(source_id.eq(source_id_))))
.get_result(&con)
.map_err(From::from)
}
#[cfg_attr(rustfmt, rustfmt_skip)]
pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.filter(podcast_id.eq(podcast_id_)).filter(title.eq(title_))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(episode)
.values(eps)
.execute(&*con)
.map_err(From::from)
.map(|_| ())
}
pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
let epoch_now = Utc::now().timestamp() as i32;
con.transaction(|| {
diesel::update(Episode::belonging_to(parent).filter(played.is_null()))
.set(played.eq(Some(epoch_now)))
.execute(&con)
.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();
index_single_source(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);
}
}

View File

@ -1,106 +0,0 @@
use diesel;
use diesel::r2d2;
use diesel_migrations::RunMigrationsError;
use hyper;
use native_tls;
use rss;
use url;
use std::io;
#[derive(Fail, Debug)]
pub enum DataError {
#[fail(display = "SQL Query failed: {}", _0)]
DieselResultError(#[cause] diesel::result::Error),
#[fail(display = "Database Migration error: {}", _0)]
DieselMigrationError(#[cause] RunMigrationsError),
#[fail(display = "R2D2 error: {}", _0)]
R2D2Error(#[cause] r2d2::Error),
#[fail(display = "R2D2 Pool error: {}", _0)]
R2D2PoolError(#[cause] r2d2::PoolError),
#[fail(display = "Hyper Error: {}", _0)]
HyperError(#[cause] hyper::Error),
#[fail(display = "Failed to parse a url: {}", _0)]
// TODO: print the url too
UrlError(#[cause] url::ParseError),
#[fail(display = "TLS Error: {}", _0)]
TLSError(#[cause] native_tls::Error),
#[fail(display = "IO Error: {}", _0)]
IOError(#[cause] io::Error),
#[fail(display = "RSS Error: {}", _0)]
RssError(#[cause] rss::Error),
#[fail(display = "Error: {}", _0)]
Bail(String),
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
},
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
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.")]
EpisodeNotChanged,
}
impl From<RunMigrationsError> for DataError {
fn from(err: RunMigrationsError) -> Self {
DataError::DieselMigrationError(err)
}
}
impl From<diesel::result::Error> for DataError {
fn from(err: diesel::result::Error) -> Self {
DataError::DieselResultError(err)
}
}
impl From<r2d2::Error> for DataError {
fn from(err: r2d2::Error) -> Self {
DataError::R2D2Error(err)
}
}
impl From<r2d2::PoolError> for DataError {
fn from(err: r2d2::PoolError) -> Self {
DataError::R2D2PoolError(err)
}
}
impl From<hyper::Error> for DataError {
fn from(err: hyper::Error) -> Self {
DataError::HyperError(err)
}
}
impl From<url::ParseError> for DataError {
fn from(err: url::ParseError) -> Self {
DataError::UrlError(err)
}
}
impl From<native_tls::Error> for DataError {
fn from(err: native_tls::Error) -> Self {
DataError::TLSError(err)
}
}
impl From<io::Error> for DataError {
fn from(err: io::Error) -> Self {
DataError::IOError(err)
}
}
impl From<rss::Error> for DataError {
fn from(err: rss::Error) -> Self {
DataError::RssError(err)
}
}
impl From<String> for DataError {
fn from(err: String) -> Self {
DataError::Bail(err)
}
}

View File

@ -1,232 +0,0 @@
//! Index Feeds.
use futures::future::*;
use itertools::{Either, Itertools};
use rss;
use dbqueries;
use errors::DataError;
use models::{Index, IndexState, Update};
use models::{NewEpisode, NewPodcast, Podcast};
use pipeline::*;
type InsertUpdate = (Vec<NewEpisode>, Vec<Option<(NewEpisode, i32)>>);
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field.
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub struct Feed {
/// The `rss::Channel` parsed from the `Source` uri.
channel: rss::Channel,
/// The `Source` id where the xml `rss::Channel` came from.
source_id: i32,
}
impl Feed {
/// Index the contents of the RSS `Feed` into the database.
pub fn index(self) -> Box<Future<Item = (), Error = DataError> + Send> {
let fut = self.parse_podcast_async()
.and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(&pd));
Box::new(fut)
}
fn parse_podcast(&self) -> NewPodcast {
NewPodcast::new(&self.channel, self.source_id)
}
fn parse_podcast_async(&self) -> Box<Future<Item = NewPodcast, Error = DataError> + Send> {
Box::new(ok(self.parse_podcast()))
}
fn index_channel_items(
&self,
pd: &Podcast,
) -> Box<Future<Item = (), Error = DataError> + Send> {
let fut = self.get_stuff(pd)
.and_then(|(insert, update)| {
if !insert.is_empty() {
info!("Indexing {} episodes.", insert.len());
if let Err(err) = dbqueries::index_new_episodes(insert.as_slice()) {
error!("Failed batch indexng, Fallign back to individual indexing.");
error!("{}", err);
insert.iter().for_each(|ep| {
if let Err(err) = ep.index() {
error!("Failed to index episode: {:?}.", ep.title());
error!("{}", err);
};
})
}
}
Ok((insert, update))
})
.map(|(_, update)| {
if !update.is_empty() {
info!("Updating {} episodes.", update.len());
// see get_stuff for more
update
.into_iter()
.filter_map(|x| x)
.for_each(|(ref ep, rowid)| {
if let Err(err) = ep.update(rowid) {
error!("Failed to index episode: {:?}.", ep.title());
error!("{}", err);
};
})
}
});
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 => 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)]
mod tests {
use rss::Channel;
use tokio_core::reactor::Core;
use Source;
use database::truncate_db;
use dbqueries;
use utils::get_feed;
use std::fs;
use std::io::BufReader;
use super::*;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test]
fn test_complete_index() {
truncate_db().unwrap();
let feeds: Vec<_> = URLS.iter()
.map(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
get_feed(path, s.id())
})
.collect();
let mut core = Core::new().unwrap();
// Index the channels
let list: Vec<_> = feeds.into_iter().map(|x| x.index()).collect();
let _foo = core.run(join_all(list));
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
#[test]
fn test_feed_parse_podcast() {
truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let file = fs::File::open(path).unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(feed.parse_podcast(), pd);
}
#[test]
fn test_feed_index_channel_items() {
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();
feed.index_channel_items(&pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
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());
}
}

View File

@ -1,104 +0,0 @@
#![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)]
#![deny(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)]
#![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts)]
#![deny(unused_extern_crates, unused)]
// #![feature(conservative_impl_trait)]
//! FIXME: Docs
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
#[macro_use]
extern crate derive_builder;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
// #[macro_use]
extern crate failure;
#[macro_use]
extern crate failure_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
extern crate ammonia;
extern crate chrono;
extern crate futures;
extern crate hyper;
extern crate hyper_tls;
extern crate itertools;
extern crate native_tls;
extern crate num_cpus;
extern crate rayon;
extern crate rfc822_sanitizer;
extern crate rss;
extern crate tokio_core;
extern crate url;
extern crate xdg;
#[allow(missing_docs)]
pub mod dbqueries;
#[allow(missing_docs)]
pub mod errors;
pub mod utils;
pub mod database;
pub mod pipeline;
pub(crate) mod models;
mod feed;
mod parser;
mod schema;
pub use feed::{Feed, FeedBuilder};
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
pub use models::Save;
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]
pub mod xdg_dirs {
use std::path::PathBuf;
use xdg;
lazy_static!{
pub(crate) static ref HAMMOND_XDG: xdg::BaseDirectories = {
xdg::BaseDirectories::with_prefix("hammond").unwrap()
};
/// XDG_DATA Directory `Pathbuf`.
pub static ref HAMMOND_DATA: PathBuf = {
HAMMOND_XDG.create_data_directory(HAMMOND_XDG.get_data_home()).unwrap()
};
/// XDG_CONFIG Directory `Pathbuf`.
pub static ref HAMMOND_CONFIG: PathBuf = {
HAMMOND_XDG.create_config_directory(HAMMOND_XDG.get_config_home()).unwrap()
};
/// XDG_CACHE Directory `Pathbuf`.
pub static ref HAMMOND_CACHE: PathBuf = {
HAMMOND_XDG.create_cache_directory(HAMMOND_XDG.get_cache_home()).unwrap()
};
/// Hammond Download Direcotry `PathBuf`.
pub static ref DL_DIR: PathBuf = {
HAMMOND_XDG.create_data_directory("Downloads").unwrap()
};
}
}

View File

@ -1,51 +0,0 @@
mod new_episode;
mod new_podcast;
mod new_source;
mod episode;
mod podcast;
mod source;
// use futures::prelude::*;
// use futures::future::*;
pub(crate) use self::episode::EpisodeCleanerQuery;
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
pub(crate) use self::new_podcast::NewPodcast;
pub(crate) use self::new_source::NewSource;
#[cfg(test)]
pub(crate) use self::new_episode::NewEpisodeBuilder;
#[cfg(test)]
pub(crate) use self::new_podcast::NewPodcastBuilder;
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetQuery};
pub use self::podcast::{Podcast, PodcastCoverQuery};
pub use self::source::Source;
#[derive(Debug, Clone, PartialEq)]
pub enum IndexState<T> {
Index(T),
Update((T, i32)),
NotChanged,
}
pub trait Insert<T, E> {
fn insert(&self) -> Result<T, E>;
}
pub trait Update<T, E> {
fn update(&self, i32) -> Result<T, E>;
}
// This might need to change in the future
pub trait Index<T, E>: Insert<T, E> + Update<T, E> {
fn index(&self) -> Result<T, E>;
}
/// FIXME: DOCS
pub trait Save<T, E> {
/// Helper method to easily save/"sync" current state of a diesel model to
/// the Database.
fn save(&self) -> Result<T, E>;
}

View File

@ -1,171 +0,0 @@
use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
use models::{Save, Source};
use schema::podcast;
use std::sync::Arc;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "podcast"]
#[derive(Debug, Clone)]
/// Diesel Model of the podcast table.
pub struct Podcast {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
favorite: bool,
archive: bool,
always_dl: bool,
source_id: i32,
}
impl Save<Podcast, DataError> for Podcast {
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Podcast, DataError> {
let db = connection();
let tempdb = db.get()?;
self.save_changes::<Podcast>(&*tempdb).map_err(From::from)
}
}
impl Podcast {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the Feed `link`.
///
/// Usually the website/homepage of the content creator.
pub fn link(&self) -> &str {
&self.link
}
/// Set the Podcast/Feed `link`.
pub fn set_link(&mut self, value: &str) {
self.link = value.to_string();
}
/// Get the `description`.
pub fn description(&self) -> &str {
&self.description
}
/// Set the `description`.
pub fn set_description(&mut self, value: &str) {
self.description = value.to_string();
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
/// Set the `image_uri`.
pub fn set_image_uri(&mut self, value: Option<&str>) {
self.image_uri = value.map(|x| x.to_string());
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Podcast` Feed.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// Represents the download policy for the `Podcast` Feed.
///
/// Reserved for the use with a Download manager, yet to be implemented.
///
/// If true Podcast Episode should be downloaded automaticly/skipping
/// the selection queue.
pub fn always_download(&self) -> bool {
self.always_dl
}
/// Set the download policy.
pub fn set_always_download(&mut self, b: bool) {
self.always_dl = b
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
}
}
#[derive(Queryable, Debug, Clone)]
/// Diesel Model of the podcast cover query.
/// Used for fetching information about a Podcast's cover.
pub struct PodcastCoverQuery {
id: i32,
title: String,
image_uri: Option<String>,
}
impl From<Podcast> for PodcastCoverQuery {
fn from(p: Podcast) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title,
image_uri: p.image_uri,
}
}
}
impl From<Arc<Podcast>> for PodcastCoverQuery {
fn from(p: Arc<Podcast>) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title.clone(),
image_uri: p.image_uri.clone(),
}
}
}
impl PodcastCoverQuery {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}

View File

@ -1,278 +0,0 @@
use diesel::SaveChangesDsl;
// use failure::ResultExt;
use rss::Channel;
use url::Url;
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper::client::HttpConnector;
use hyper::header::{ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified,
Location, UserAgent};
use hyper_tls::HttpsConnector;
// use futures::future::ok;
use futures::prelude::*;
use database::connection;
use errors::DataError;
use feed::{Feed, FeedBuilder};
use models::{NewSource, Save};
use schema::source;
use std::str::FromStr;
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
#[table_name = "source"]
#[changeset_options(treat_none_as_null = "true")]
#[derive(Debug, Clone)]
/// Diesel Model of the source table.
pub struct Source {
id: i32,
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl Save<Source, DataError> for Source {
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Source, DataError> {
let db = connection();
let con = db.get()?;
self.save_changes::<Source>(&con).map_err(From::from)
}
}
impl Source {
/// Get the source `id` column.
pub fn id(&self) -> i32 {
self.id
}
/// Represents the location(usually url) of the Feed xml file.
pub fn uri(&self) -> &str {
&self.uri
}
/// Set the `uri` field value.
pub fn set_uri(&mut self, uri: String) {
self.uri = uri;
}
/// Represents the Http Last-Modified Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn last_modified(&self) -> Option<&str> {
self.last_modified.as_ref().map(|s| s.as_str())
}
/// Set `last_modified` value.
pub fn set_last_modified(&mut self, value: Option<String>) {
// self.last_modified = value.map(|x| x.to_string());
self.last_modified = value;
}
/// Represents the Http Etag Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn http_etag(&self) -> Option<&str> {
self.http_etag.as_ref().map(|s| s.as_str())
}
/// Set `http_etag` value.
pub fn set_http_etag(&mut self, value: Option<&str>) {
self.http_etag = value.map(|x| x.to_string());
}
/// Extract Etag and LastModifier from res, and update self and the
/// corresponding db row.
fn update_etag(&mut self, res: &Response) -> Result<(), DataError> {
let headers = res.headers();
let etag = headers.get::<ETag>().map(|x| x.tag());
let lmod = headers.get::<LastModified>().map(|x| format!("{}", x));
if (self.http_etag() != etag) || (self.last_modified != lmod) {
self.set_http_etag(etag);
self.set_last_modified(lmod);
self.save()?;
}
Ok(())
}
fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: context.into(),
}
}
// TODO match on more stuff
// 301: Moved Permanently
// 304: Up to date Feed, checked with the Etag
// 307: Temporary redirect of the url
// 308: Permanent redirect of the url
// 401: Unathorized
// 403: Forbidden
// 408: Timeout
// 410: Feed deleted
// TODO: Rething this api,
fn match_status(mut self, res: Response) -> Result<(Self, Response), DataError> {
self.update_etag(&res)?;
let code = res.status();
match code {
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
StatusCode::MovedPermanently => {
error!("Feed was moved permanently.");
self.handle_301(&res)?;
return Err(self.make_err("301: Feed was moved permanently.", code));
}
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)),
StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)),
StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)),
StatusCode::RequestTimeout => return Err(self.make_err("408: Request Timeout.", code)),
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
_ => info!("HTTP StatusCode: {}", code),
};
Ok((self, res))
}
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
let headers = res.headers();
if let Some(url) = headers.get::<Location>() {
self.set_uri(url.to_string());
self.http_etag = None;
self.last_modified = None;
self.save()?;
info!("Feed url was updated succesfully.");
// TODO: Refresh in place instead of next time, Not a priority.
info!("New content will be fetched with the next refesh.");
}
Ok(())
}
/// Construct a new `Source` with the given `uri` and index it.
///
/// This only indexes the `Source` struct, not the Podcast Feed.
pub fn from_url(uri: &str) -> Result<Source, DataError> {
let url = Url::parse(uri)?;
NewSource::new(&url).to_source()
}
/// `Feed` constructor.
///
/// Fetches the latest xml Feed.
///
/// Updates the validator Http Headers.
///
/// Consumes `self` and Returns the corresponding `Feed` Object.
// Refactor into TryInto once it lands on stable.
pub fn into_feed(
self,
client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = Feed, Error = DataError>> {
let id = self.id();
let feed = self.request_constructor(client, ignore_etags)
.and_then(move |(_, res)| response_to_channel(res))
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.map_err(From::from)
});
Box::new(feed)
}
// TODO: make ignore_etags an Enum for better ergonomics.
// #bools_are_just_2variant_enmus
fn request_constructor(
self,
client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = (Self, Response), Error = DataError>> {
// FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::new(Method::Get, uri);
// Set the user agent as a fix for issue #53
// TODO: keep this in sync with tor-browser releases
req.headers_mut().set(UserAgent::new(
"Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0",
));
if !ignore_etags {
if let Some(foo) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(vec![
EntityTag::new(true, foo.to_owned()),
]));
}
if let Some(foo) = self.last_modified() {
if let Ok(x) = foo.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(x));
}
}
}
let work = client
.request(req)
.map_err(From::from)
// TODO: tail recursion loop that would follow redirects directly
.and_then(move |res| self.match_status(res));
Box::new(work)
}
}
#[allow(needless_pass_by_value)]
fn response_to_channel(res: Response) -> Box<Future<Item = Channel, Error = DataError> + Send> {
let chan = res.body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| Channel::from_str(&buf).map_err(From::from));
Box::new(chan)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio_core::reactor::Core;
use database::truncate_db;
use utils::get_feed;
#[test]
fn test_into_feed() {
truncate_db().unwrap();
let mut core = Core::new().unwrap();
let client = Client::configure()
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
.build(&core.handle());
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url).unwrap();
let id = source.id();
let feed = source.into_feed(&client, true);
let feed = core.run(feed).unwrap();
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
assert_eq!(expected, feed);
}
}

View File

@ -1,197 +0,0 @@
// FIXME:
//! Docs.
use futures::future::*;
// use futures::prelude::*;
use hyper::Client;
use hyper::client::HttpConnector;
use hyper_tls::HttpsConnector;
use tokio_core::reactor::Core;
use num_cpus;
use rss;
use Source;
use dbqueries;
use errors::DataError;
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
// use std::sync::{Arc, Mutex};
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`.
///
/// Messy temp diagram:
/// Source -> GET Request -> Update Etags -> Check Status -> Parse xml/Rss ->
/// Convert `rss::Channel` into Feed -> Index Podcast -> Index Episodes.
pub fn pipeline<S: IntoIterator<Item = Source>>(
sources: S,
ignore_etags: bool,
tokio_core: &mut Core,
client: Client<HttpsConnector<HttpConnector>>,
) -> Result<(), DataError> {
let list: Vec<_> = sources
.into_iter()
.map(move |s| s.into_feed(&client, ignore_etags))
.map(|fut| fut.and_then(|feed| 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`, 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 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, client)
}
/// Docs
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
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, ignore_etags)
.and_then(move |feed| 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
F: 'static + Future,
<F as Future>::Item: 'static,
<F as Future>::Error: 'static,
{
Box::new(loop_fn((futures, vec![]), |(futures, mut done)| {
select_all(futures).then(|r| {
let (r, rest) = match r {
Ok((r, _, rest)) => (Ok(r), rest),
Err((r, _, rest)) => (Err(r), rest),
};
done.push(r);
if rest.is_empty() {
Ok(Loop::Break(done))
} else {
Ok(Loop::Continue((rest, done)))
}
})
}))
}
#[cfg(test)]
mod tests {
use super::*;
use Source;
use database::truncate_db;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test]
/// Insert feeds and update/index them.
fn test_pipeline() {
truncate_db().unwrap();
URLS.iter().for_each(|&(_, url)| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
let sources = dbqueries::get_sources().unwrap();
run(sources, true).unwrap();
let sources = dbqueries::get_sources().unwrap();
// Run again to cover Unique constrains erros.
run(sources, true).unwrap();
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
}

View File

@ -1,22 +0,0 @@
[package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
name = "hammond-downloader"
version = "0.1.0"
workspace = "../"
[dependencies]
error-chain = "0.11.0"
hyper = "0.11.24"
log = "0.4.1"
mime_guess = "1.8.4"
reqwest = "0.8.5"
tempdir = "0.3.7"
glob = "0.2.11"
failure = "0.1.1"
failure_derive = "0.1.1"
[dependencies.hammond-data]
path = "../hammond-data"
[dev-dependencies]
pretty_assertions = "0.5.1"

View File

@ -1,41 +0,0 @@
use hammond_data::errors::DataError;
use reqwest;
use std::io;
#[derive(Fail, Debug)]
pub enum DownloadError {
#[fail(display = "Reqwest error: {}", _0)]
RequestError(#[cause] reqwest::Error),
#[fail(display = "Data error: {}", _0)]
DataError(#[cause] DataError),
#[fail(display = "Io error: {}", _0)]
IoError(#[cause] io::Error),
#[fail(display = "Unexpected server response: {}", _0)]
UnexpectedResponse(reqwest::StatusCode),
#[fail(display = "The Download was cancelled.")]
DownloadCancelled,
#[fail(display = "Remote Image location not found.")]
NoImageLocation,
#[fail(display = "Failed to parse CacheLocation.")]
InvalidCacheLocation,
#[fail(display = "Failed to parse Cached Image Location.")]
InvalidCachedImageLocation,
}
impl From<reqwest::Error> for DownloadError {
fn from(err: reqwest::Error) -> Self {
DownloadError::RequestError(err)
}
}
impl From<io::Error> for DownloadError {
fn from(err: io::Error) -> Self {
DownloadError::IoError(err)
}
}
impl From<DataError> for DownloadError {
fn from(err: DataError) -> Self {
DownloadError::DataError(err)
}
}

View File

@ -1,24 +0,0 @@
#![recursion_limit = "1024"]
#![deny(unused_extern_crates, unused)]
#![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
extern crate failure;
#[macro_use]
extern crate failure_derive;
#[macro_use]
extern crate log;
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate glob;
extern crate hammond_data;
extern crate hyper;
extern crate mime_guess;
extern crate reqwest;
extern crate tempdir;
pub mod downloader;
pub mod errors;

View File

@ -1,44 +0,0 @@
[package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
build = "build.rs"
name = "hammond-gtk"
version = "0.1.0"
workspace = "../"
[dependencies]
chrono = "0.4.1"
gdk = "0.8.0"
gdk-pixbuf = "0.4.0"
glib = "0.5.0"
humansize = "1.1.0"
lazy_static = "1.0.0"
log = "0.4.1"
loggerv = "0.7.1"
open = "1.2.1"
rayon = "1.0.1"
send-cell = "0.1.2"
url = "1.7.0"
failure = "0.1.1"
failure_derive = "0.1.1"
take_mut = "0.2.2"
regex = "0.2.10"
reqwest = "0.8.5"
serde_json = "1.0.13"
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
[dependencies.gtk]
features = ["v3_22"]
version = "0.4.0"
[dependencies.gio]
features = ["v2_50"]
version = "0.4.0"
[dependencies.hammond-data]
path = "../hammond-data"
[dependencies.hammond-downloader]
path = "../hammond-downloader"
[dev-dependencies]
pretty_assertions = "0.5.1"

View File

@ -1,9 +0,0 @@
use std::process::Command;
fn main() {
Command::new("glib-compile-resources")
.args(&["--generate", "resources.xml"])
.current_dir("resources")
.status()
.unwrap();
}

View File

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="empty_show">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">application-rss+xml-symbolic</property>
<property name="use_fallback">True</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">This show does not have any episodes</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.4399999999999999"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">If you think this is an Error, Plese consider opening a bug report.</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="empty_view">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">application-rss+xml-symbolic</property>
<property name="use_fallback">True</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">No Feed Subscription Found</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.4399999999999999"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">You can subscribe to feeds using the "+" button</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,405 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="name">container</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="height_request">400</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="frame_parent">
<property name="width_request">600</property>
<property name="height_request">-1</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_top">32</property>
<property name="margin_bottom">32</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkBox" id="today_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Today</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="today_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="yday_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Yesterday</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="yday_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="week_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Week</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="week_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="month_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Month</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="month_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkBox" id="rest_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Older</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="rest_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">5</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,295 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface domain="">
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="width_request">700</property>
<property name="height_request">500</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can_default">True</property>
<property name="hexpand">True</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="width_request">700</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_top">32</property>
<property name="margin_bottom">32</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="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">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">end</property>
<property name="orientation">vertical</property>
<property name="spacing">6</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">foo</property>
<property name="use_markup">True</property>
<property name="wrap">True</property>
<property name="wrap_mode">word-char</property>
<property name="max_width_chars">90</property>
</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="hexpand">True</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">True</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</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">1</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">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<object class="GtkPopover" id="show_menu">
<property name="can_focus">False</property>
<property name="position">bottom</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton" id="mark_all_watched">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Mark all episodes as listened</property>
<property name="centered">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
</interface>

View File

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_child">
<property name="width_request">256</property>
<property name="height_request">256</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkOverlay">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<placeholder/>
</child>
<child type="overlay">
<object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
<property name="icon_size">0</property>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,72 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_parent">
<property name="name">fb_parent</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkFlowBox" id="flowbox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</property>
<property name="homogeneous">True</property>
<property name="column_spacing">12</property>
<property name="row_spacing">12</property>
<property name="max_children_per_line">20</property>
<property name="selection_mode">none</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,11 +0,0 @@
row {
border-bottom: solid 1px rgba(0,0,0, 0.1);
}
row:last-child {
border-bottom: none;
}
list, border {
border-radius: 4px;
}

View File

@ -1,6 +0,0 @@
# subdir('icons')
install_data('org.gnome.Hammond.desktop', install_dir : datadir + '/applications')
install_data('org.gnome.Hammond.appdata.xml', install_dir : datadir + '/appdata')
install_data('org.gnome.Hammond.gschema.xml', install_dir: join_paths(datadir, 'glib-2.0', 'schemas'))
meson.add_install_script('../../scripts/compile-gschema.py')

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>org.gnome.Hammond</id>
<name>Hammond</name>
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<developer_name>Jordan Petridis</developer_name>
<summary>Modern Podcast Client for the GNOME desktop</summary>
<description xml:lang="en">
<p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
desktop written in Rust.
</p>
</description>
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
<screenshots>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.3.1" date="2018-03-28"/>
</releases>
<url type="homepage">https://gitlab.gnome.org/alatiera/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name>
</component>

View File

@ -1,11 +0,0 @@
[Desktop Entry]
Name=Hammond
GenericName=Podcast Client
Comment=Play, Subscribe and Manage Podcast Feeds.
Icon=multimedia-player
Exec=hammond
Terminal=false
Type=Application
StartupNotify=true
Categories=AudioVideo;Audio;Video;
Keywords=Podcast

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/hammond/">
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_show.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file>
<file compressed="true">gtk/style.css</file>
</gresource>
</gresources>

View File

@ -1,267 +0,0 @@
#![allow(new_without_default)]
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt};
use glib;
use gtk;
use gtk::SettingsExt as GtkSettingsExt;
use gtk::prelude::*;
use failure::Error;
use rayon;
use hammond_data::{Podcast, Source};
use hammond_data::utils::delete_show;
use appnotif::*;
use headerbar::Header;
use settings::WindowGeometry;
use stacks::Content;
use utils;
use widgets::mark_all_watched;
use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration;
#[derive(Clone, Debug)]
pub enum Action {
UpdateSources(Option<Source>),
RefreshAllViews,
RefreshEpisodesView,
RefreshEpisodesViewBGR,
RefreshShowsView,
RefreshWidget,
RefreshWidgetIfVis,
ReplaceWidget(Arc<Podcast>),
RefreshWidgetIfSame(i32),
ShowWidgetAnimated,
ShowShowsAnimated,
HeaderBarShowTile(String),
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>),
}
#[derive(Debug)]
pub struct App {
app_instance: gtk::Application,
window: gtk::Window,
overlay: gtk::Overlay,
header: Arc<Header>,
content: Arc<Content>,
receiver: Receiver<Action>,
sender: Sender<Action>,
settings: Settings,
}
impl App {
pub fn new() -> App {
let settings = Settings::new("org.gnome.Hammond");
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
.expect("Application Initialization failed...");
// Weird magic I copy-pasted that sets the Application Name in the Shell.
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
// Create the main window
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title("Hammond");
let app_clone = application.clone();
let window_clone = window.clone();
let settings_clone = settings.clone();
window.connect_delete_event(move |_, _| {
WindowGeometry::from_window(&window_clone).write(&settings_clone);
app_clone.quit();
Inhibit(false)
});
let (sender, receiver) = channel();
// Create a content instance
let content =
Arc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
// Create the headerbar
let header = Arc::new(Header::new(&content, &window, sender.clone()));
// Add the content main stack to the overlay.
let overlay = gtk::Overlay::new();
overlay.add(&content.get_stack());
// Add the overlay to the main window
window.add(&overlay);
App {
app_instance: application,
window,
overlay,
header,
content,
receiver,
sender,
settings,
}
}
fn setup_timed_callbacks(&self) {
self.setup_dark_theme();
self.setup_refresh_on_startup();
self.setup_auto_refresh();
}
fn setup_dark_theme(&self) {
let settings = gtk::Settings::get_default().unwrap();
let enabled = self.settings.get_boolean("dark-theme");
settings.set_property_gtk_application_prefer_dark_theme(enabled);
}
fn setup_refresh_on_startup(&self) {
// Update the feeds right after the Application is initialized.
if self.settings.get_boolean("refresh-on-startup") {
let cleanup_date = utils::get_cleanup_date(&self.settings);
let sender = self.sender.clone();
info!("Refresh on startup.");
utils::cleanup(cleanup_date);
gtk::timeout_add_seconds(2, move || {
utils::refresh(None, sender.clone());
glib::Continue(false)
});
}
}
fn setup_auto_refresh(&self) {
let refresh_interval = utils::get_refresh_interval(&self.settings).num_seconds() as u32;
let sender = self.sender.clone();
info!("Auto-refresh every {:?} seconds.", refresh_interval);
gtk::timeout_add_seconds(refresh_interval, move || {
utils::refresh(None, sender.clone());
glib::Continue(true)
});
}
pub fn run(self) {
WindowGeometry::from_settings(&self.settings).apply(&self.window);
let window = self.window.clone();
self.app_instance.connect_startup(move |app| {
build_ui(&window, app);
});
self.setup_timed_callbacks();
let content = self.content.clone();
let headerbar = self.header.clone();
let sender = self.sender.clone();
let overlay = self.overlay.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(Some(vec![s]), sender.clone());
} else {
utils::refresh(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(),
Ok(Action::MarkAllPlayerNotification(pd)) => {
let callback = clone!(sender => move || {
if let Err(err) = mark_all_watched(&pd, sender.clone()) {
error!("Something went horribly wrong with the notif callback: {}", err);
}
glib::Continue(false)
});
let text = "Marked all episodes as listened".into();
let notif = InAppNotification::new(text, callback, || {}, sender.clone());
notif.show(&overlay);
}
Ok(Action::RemoveShow(pd)) => {
let text = format!("Unsubscribed from {}", pd.title());
if let Err(err) = utils::ignore_show(pd.id()) {
error!("Could not insert {} to the ignore list.", pd.title());
error!("Error: {}", err);
}
let callback = clone!(pd => move || {
if let Err(err) = utils::uningore_show(pd.id()) {
error!("Could not remove {} from the ignore list.", pd.title());
error!("Error: {}", err);
}
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd => move || {
if let Err(err) = delete_show(&pd) {
error!("Something went wrong trying to remove {}", pd.title());
error!("Error: {}", err);
}
}));
glib::Continue(false)
});
let sender_ = sender.clone();
let undo_wrap = move || -> Result<(), Error> {
utils::uningore_show(pd.id())?;
sender_.send(Action::RefreshShowsView)?;
sender_.send(Action::RefreshEpisodesView)?;
Ok(())
};
let undo_callback = move || {
if let Err(err) = undo_wrap() {
error!("{}", err)
}
};
let sender_ = sender.clone();
let notif = InAppNotification::new(text, callback, undo_callback, sender_);
notif.show(&overlay);
}
Err(_) => (),
}
Continue(true)
});
ApplicationExtManual::run(&self.app_instance, &[]);
}
}
fn build_ui(window: &gtk::Window, app: &gtk::Application) {
window.set_application(app);
window.show_all();
window.activate();
app.connect_activate(move |_| ());
}

View File

@ -1,95 +0,0 @@
use glib;
use gtk;
use gtk::prelude::*;
use app::Action;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::Sender;
#[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: String,
mut callback: F,
undo_callback: U,
sender: Sender<Action>,
) -> 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);
// Refresh the widget if visible
if let Err(err) = sender.send(Action::RefreshWidgetIfVis) {
error!("Action channel blew up: {}", err)
}
});
// Hide the revealer when the close button is clicked
let revealer = notif.revealer.clone();
notif.close.connect_clicked(move |_| {
revealer.set_reveal_child(false);
});
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: &gtk::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);
}
}

View File

@ -1,253 +0,0 @@
use gtk;
use gtk::prelude::*;
use failure::Error;
use failure::ResultExt;
use url::Url;
use hammond_data::Source;
use hammond_data::dbqueries;
use std::sync::mpsc::Sender;
use app::Action;
use stacks::Content;
use utils::itunes_to_rss;
#[derive(Debug, Clone)]
pub struct Header {
container: gtk::HeaderBar,
add_toggle: gtk::MenuButton,
switch: gtk::StackSwitcher,
back_button: gtk::Button,
show_title: gtk::Label,
about_button: gtk::ModelButton,
update_button: gtk::ModelButton,
update_box: gtk::Box,
update_label: gtk::Label,
update_spinner: gtk::Spinner,
}
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
let add_toggle: gtk::MenuButton = builder.get_object("add_toggle").unwrap();
let switch: gtk::StackSwitcher = builder.get_object("switch").unwrap();
let back_button: gtk::Button = builder.get_object("back_button").unwrap();
let show_title: gtk::Label = builder.get_object("show_title").unwrap();
let update_button: gtk::ModelButton = builder.get_object("update_button").unwrap();
let update_box: gtk::Box = builder.get_object("update_notification").unwrap();
let update_label: gtk::Label = builder.get_object("update_label").unwrap();
let update_spinner: gtk::Spinner = builder.get_object("update_spinner").unwrap();
let about_button: gtk::ModelButton = builder.get_object("about_button").unwrap();
Header {
container: header,
add_toggle,
switch,
back_button,
show_title,
about_button,
update_button,
update_box,
update_label,
update_spinner,
}
}
}
// TODO: Refactor components into smaller state machines
impl Header {
pub fn new(content: &Content, window: &gtk::Window, sender: Sender<Action>) -> Header {
let h = Header::default();
h.init(content, window, sender);
h
}
pub fn init(&self, content: &Content, window: &gtk::Window, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
let add_button: gtk::Button = builder.get_object("add_button").unwrap();
let result_label: gtk::Label = builder.get_object("result_label").unwrap();
self.switch.set_stack(&content.get_stack());
new_url.connect_changed(clone!(add_button => move |url| {
if let Err(err) = on_url_change(url, &result_label, &add_button) {
error!("Error: {}", err);
}
}));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
if let Err(err) = on_add_bttn_clicked(&new_url, sender.clone()) {
error!("Error: {}", err);
}
add_popover.hide();
}));
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.
window.set_titlebar(&self.container);
let switch = &self.switch;
let add_toggle = &self.add_toggle;
let show_title = &self.show_title;
self.back_button.connect_clicked(
clone!(switch, add_toggle, show_title, sender => move |back| {
switch.show();
add_toggle.show();
back.hide();
show_title.hide();
if let Err(err) = sender.send(Action::ShowShowsAnimated) {
error!("Action channel blew up: {}", err);
}
}),
);
}
pub fn switch_to_back(&self, title: &str) {
self.switch.hide();
self.add_toggle.hide();
self.back_button.show();
self.set_show_title(title);
self.show_title.show();
}
pub fn switch_to_normal(&self) {
self.switch.show();
self.add_toggle.show();
self.back_button.hide();
self.show_title.hide();
}
pub fn set_show_title(&self, title: &str) {
self.show_title.set_text(title)
}
pub fn show_update_notification(&self) {
self.update_spinner.start();
self.update_box.show();
self.update_spinner.show();
self.update_label.show();
}
pub fn hide_update_notification(&self) {
self.update_spinner.stop();
self.update_box.hide();
self.update_spinner.hide();
self.update_label.hide();
}
}
// FIXME: THIS ALSO SUCKS!
fn on_add_bttn_clicked(entry: &gtk::Entry, sender: Sender<Action>) -> Result<(), Error> {
let url = entry.get_text().unwrap_or_default();
let url = if url.contains("itunes.com") || url.contains("apple.com") {
info!("Detected itunes url.");
let foo = itunes_to_rss(&url)?;
info!("Resolved to {}", foo);
foo
} else {
url.to_owned()
};
let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?;
entry.set_text("");
sender
.send(Action::UpdateSources(Some(source)))
.context("App channel blew up.")?;
Ok(())
}
// FIXME: THIS SUCKS!
fn on_url_change(
entry: &gtk::Entry,
result: &gtk::Label,
add_button: &gtk::Button,
) -> Result<(), Error> {
let uri = entry
.get_text()
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
debug!("Url: {}", uri);
let url = Url::parse(&uri);
// TODO: refactor to avoid duplication
match url {
Ok(u) => {
if !dbqueries::source_exists(u.as_str())? {
add_button.set_sensitive(true);
result.hide();
result.set_label("");
} else {
add_button.set_sensitive(false);
result.set_label("Show already exists.");
result.show();
}
Ok(())
}
Err(err) => {
add_button.set_sensitive(false);
if !uri.is_empty() {
result.set_label("Invalid url.");
result.show();
error!("Error: {}", err);
} else {
result.hide();
}
Ok(())
}
}
}
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
fn about_dialog(window: &gtk::Window) {
// Feel free to add yourself if you contribured.
let authors = &[
"Constantin Nickel",
"Gabriele Musco",
"James Wykeham-Martin",
"Jordan Petridis",
"Julian Sparber",
"Rowan Lewis",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.3.1");
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();
}

View File

@ -1,97 +0,0 @@
#![cfg_attr(feature = "cargo-clippy",
allow(clone_on_ref_ptr, needless_pass_by_value, useless_format, blacklisted_name,
match_same_arms))]
#![allow(unknown_lints)]
#![deny(unused_extern_crates, unused)]
extern crate gdk;
extern crate gdk_pixbuf;
extern crate gio;
extern crate glib;
extern crate gtk;
#[macro_use]
extern crate failure;
// #[macro_use]
// extern crate failure_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate chrono;
extern crate hammond_data;
extern crate hammond_downloader;
extern crate html2pango;
extern crate humansize;
extern crate loggerv;
extern crate open;
extern crate rayon;
extern crate regex;
extern crate reqwest;
extern crate send_cell;
extern crate serde_json;
extern crate take_mut;
extern crate url;
use log::Level;
use gtk::prelude::*;
// http://gtk-rs.org/tuto/closures
#[macro_export]
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
// They do not need to be public
// But it helps when looking at the generated docs.
pub mod views;
pub mod widgets;
pub mod stacks;
pub mod headerbar;
pub mod app;
pub mod settings;
pub mod utils;
pub mod manager;
pub mod static_resource;
pub mod appnotif;
use app::App;
fn main() {
// TODO: make the the logger a cli -vv option
loggerv::init_with_level(Level::Info).expect("Error initializing loggerv.");
gtk::init().expect("Error initializing gtk.");
static_resource::init().expect("Something went wrong with the resource file initialization.");
// Add custom style
let provider = gtk::CssProvider::new();
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
gtk::StyleContext::add_provider_for_screen(
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
&provider,
600,
);
App::new().run();
}

View File

@ -1,90 +0,0 @@
use gio;
use gio::SettingsExt;
use gtk;
use gtk::GtkWindowExt;
pub struct WindowGeometry {
left: i32,
top: i32,
width: i32,
height: i32,
is_maximized: bool,
}
impl WindowGeometry {
pub fn from_window(window: &gtk::Window) -> 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: &gtk::Window) {
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);
}
}
// #[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());
// }

View File

@ -1,94 +0,0 @@
use gtk;
use gtk::prelude::*;
use failure::Error;
use app::Action;
use stacks::EpisodeStack;
use stacks::ShowStack;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct Content {
stack: gtk::Stack,
shows: Arc<ShowStack>,
episodes: Arc<EpisodeStack>,
sender: Sender<Action>,
}
impl Content {
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
let stack = gtk::Stack::new();
let episodes = Arc::new(EpisodeStack::new(sender.clone())?);
let shows = Arc::new(ShowStack::new(sender.clone())?);
stack.add_titled(&episodes.get_stack(), "episodes", "Episodes");
stack.add_titled(&shows.get_stack(), "shows", "Shows");
Ok(Content {
stack,
shows,
episodes,
sender,
})
}
pub fn update(&self) {
self.update_episode_view();
self.update_shows_view();
self.update_widget()
}
// TODO: Maybe propagate the error?
pub fn update_episode_view(&self) {
if let Err(err) = self.episodes.update() {
error!("Something went wrong while trying to update the episode view.");
error!("Error: {}", err);
}
}
pub fn update_episode_view_if_baground(&self) {
if self.stack.get_visible_child_name() != Some("episodes".into()) {
self.update_episode_view();
}
}
pub fn update_shows_view(&self) {
if let Err(err) = self.shows.update_podcasts() {
error!("Something went wrong while trying to update the ShowsView.");
error!("Error: {}", err);
}
}
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) {
if let Err(err) = self.shows.update_widget_if_same(pid) {
error!("Something went wrong while trying to update the Show Widget.");
error!("Error: {}", err);
}
}
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 {
self.stack.clone()
}
pub fn get_shows(&self) -> Arc<ShowStack> {
self.shows.clone()
}
}

View File

@ -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()
}
}

View File

@ -1,7 +0,0 @@
mod content;
mod episode;
mod show;
pub use self::content::Content;
pub use self::episode::EpisodeStack;
pub use self::show::ShowStack;

View File

@ -1,181 +0,0 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use failure::Error;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use views::{EmptyView, ShowsPopulated};
use app::Action;
use widgets::ShowWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct ShowStack {
stack: gtk::Stack,
sender: Sender<Action>,
}
impl ShowStack {
pub fn new(sender: Sender<Action>) -> Result<ShowStack, Error> {
let stack = gtk::Stack::new();
let show = ShowStack {
stack,
sender: sender.clone(),
};
let pop = ShowsPopulated::new(sender.clone())?;
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)
}
// 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 {
self.stack.clone()
}
}

View File

@ -1,18 +0,0 @@
use gio::{resources_register, Error, Resource};
use glib::Bytes;
pub fn init() -> Result<(), Error> {
// load the gresource binary at build time and include/link it into the final
// binary.
let res_bytes = include_bytes!("../resources/resources.gresource");
// Create Resource it will live as long the value lives.
let gbytes = Bytes::from_static(res_bytes.as_ref());
let resource = Resource::new_from_data(&gbytes)?;
// Register the resource so It wont be dropped and will continue to live in
// memory.
resources_register(&resource);
Ok(())
}

View File

@ -1,330 +0,0 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use gdk_pixbuf::Pixbuf;
use gio::{Settings, SettingsExt};
use glib;
use gtk;
use gtk::prelude::*;
use failure::Error;
use rayon;
use regex::Regex;
use reqwest;
use send_cell::SendCell;
use serde_json::Value;
// use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::utils::checkup;
use hammond_downloader::downloader;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, RwLock};
use std::sync::Arc;
use std::sync::mpsc::*;
use app::Action;
use chrono::Duration;
use chrono::prelude::*;
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>) {
if let Err(err) = checkup(cleanup_date) {
error!("Check up failed: {}", err);
}
}
pub fn refresh(source: Option<Vec<Source>>, sender: Sender<Action>) {
if let Err(err) = refresh_feed(source, sender) {
error!("An error occured while trying to update the feeds.");
error!("Error: {}", err);
}
}
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = settings.get_int("refresh-interval-time") as i64;
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 = settings.get_int("cleanup-age-time") as i64;
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
}
/// Update the rss feed(s) originating from `source`.
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
/// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
sender.send(Action::HeaderBarShowUpdateIndicator)?;
rayon::spawn(move || {
let mut sources = source.unwrap_or_else(|| {
dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
});
// Work around to improve the feed addition experience.
// Many times links to rss feeds are just redirects(usually to an https
// version). 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);
}
}
}
// This is what would normally run
} else if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
sender
.send(Action::HeaderBarHideUpdateIndicator)
.expect("Action channel blew up.");
sender
.send(Action::RefreshAllViews)
.expect("Action channel blew up.");
});
Ok(())
}
lazy_static! {
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
{ RwLock::new(HashMap::new()) };
static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
}
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
// use the cover of the Podcast Feed/Show, We can only create a Pixbuf
// cover per show and pass around the Rc pointer.
//
// GObjects do not implement Send trait, so SendCell is a way around that.
// Also lazy_static requires Sync trait, so that's what the mutexes are.
// TODO: maybe use something that would just scale to requested size?
pub fn set_image_from_path(
image: &gtk::Image,
pd: Arc<PodcastCoverQuery>,
size: u32,
) -> Result<(), Error> {
// Check if there's an active download about this show cover.
// If there is, a callback will be set so this function will be called again.
// If the download succedes, there should be a quick return from the pixbuf cache_image
// If it fails another download will be scheduled.
if let Ok(guard) = COVER_DL_REGISTRY.read() {
if guard.contains(&pd.id()) {
let callback = clone!(image, pd => move || {
let _ = set_image_from_path(&image, pd.clone(), size);
glib::Continue(false)
});
gtk::timeout_add(250, callback);
return Ok(());
}
}
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
// Check if the requested (cover + size) is already in the chache
// and if so do an early return after that.
if let Some(guard) = hashmap.get(&(pd.id(), size)) {
guard
.lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err))
.and_then(|sendcell| {
sendcell
.try_get()
.map(|px| image.set_from_pixbuf(px))
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
})?;
return Ok(());
}
}
let (sender, receiver) = channel();
let pd_ = pd.clone();
THREADPOOL.spawn(move || {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.insert(pd_.id());
}
let _ = sender.send(downloader::cache_image(&pd_));
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&pd_.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((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px);
}
}
}
glib::Continue(false)
} else {
glib::Continue(true)
}
});
Ok(())
}
#[inline]
// 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)
}
#[inline]
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()
}
#[inline]
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 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),
}
}
#[cfg(test)]
mod tests {
use super::*;
// use hammond_data::Source;
// use hammond_data::dbqueries;
#[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]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
// #[ignore]
// Disabled till https://gitlab.gnome.org/alatiera/Hammond/issues/56
// fn test_set_image_from_path() {
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
// let source = Source::from_url(url).unwrap();
// Copy it's id
// let sid = source.id();
// pipeline::run(vec![source], true).unwrap();
// Get the Podcast
// let img = gtk::Image::new();
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
// let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
// assert!(pxbuf.is_ok());
// }
#[test]
fn test_itunes_to_rss() {
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
assert!(itunes_to_rss(itunes_url).is_err());
}
#[test]
fn test_itunes_id() {
let id = 1195206601;
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
}
#[test]
fn test_itunes_lookup_id() {
let id = 1195206601;
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
assert_eq!(rss_url, lookup_id(id).unwrap());
let id = 000000000;
assert!(lookup_id(id).is_err());
}
}

View File

@ -1,21 +0,0 @@
use gtk;
#[derive(Debug, Clone)]
pub struct EmptyView {
pub container: gtk::Box,
}
impl Default for EmptyView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
let view: gtk::Box = builder.get_object("empty_view").unwrap();
EmptyView { container: view }
}
}
impl EmptyView {
pub fn new() -> EmptyView {
EmptyView::default()
}
}

View File

@ -1,234 +0,0 @@
use chrono::prelude::*;
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::EpisodeWidgetQuery;
use hammond_data::dbqueries;
use app::Action;
use utils::{get_ignored_shows, set_image_from_path};
use widgets::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
enum ListSplit {
Today,
Yday,
Week,
Month,
Rest,
}
#[derive(Debug, Clone)]
pub struct EpisodesView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box,
today_box: gtk::Box,
yday_box: gtk::Box,
week_box: gtk::Box,
month_box: gtk::Box,
rest_box: gtk::Box,
today_list: gtk::ListBox,
yday_list: gtk::ListBox,
week_list: gtk::ListBox,
month_list: gtk::ListBox,
rest_list: gtk::ListBox,
}
impl Default for EpisodesView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
let today_box: gtk::Box = builder.get_object("today_box").unwrap();
let yday_box: gtk::Box = builder.get_object("yday_box").unwrap();
let week_box: gtk::Box = builder.get_object("week_box").unwrap();
let month_box: gtk::Box = builder.get_object("month_box").unwrap();
let rest_box: gtk::Box = builder.get_object("rest_box").unwrap();
let today_list: gtk::ListBox = builder.get_object("today_list").unwrap();
let yday_list: gtk::ListBox = builder.get_object("yday_list").unwrap();
let week_list: gtk::ListBox = builder.get_object("week_list").unwrap();
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
EpisodesView {
container,
scrolled_window,
frame_parent,
today_box,
yday_box,
week_box,
month_box,
rest_box,
today_list,
yday_list,
week_list,
month_list,
rest_list,
}
}
}
// TODO: REFACTOR ME
impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
let view = EpisodesView::default();
let ignore = get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 50)?;
let now_utc = Utc::now();
episodes.into_iter().for_each(|ep| {
let epoch = ep.epoch();
let viewep = EpisodesViewWidget::new(ep, sender.clone());
let t = split(&now_utc, i64::from(epoch));
match t {
ListSplit::Today => {
view.today_list.add(&viewep.container);
}
ListSplit::Yday => {
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() {
view.today_box.hide();
}
if view.yday_list.get_children().is_empty() {
view.yday_box.hide();
}
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();
}
view.container.show_all();
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.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
let ep = Utc.timestamp(epoch, 0);
if now.ordinal() == ep.ordinal() && now.year() == ep.year() {
ListSplit::Today
} else if now.ordinal() == ep.ordinal() + 1 && now.year() == ep.year() {
ListSplit::Yday
} else if now.iso_week().week() == ep.iso_week().week() && now.year() == ep.year() {
ListSplit::Week
} else if now.month() == ep.month() && now.year() == ep.year() {
ListSplit::Month
} else {
ListSplit::Rest
}
}
#[derive(Debug, Clone)]
struct EpisodesViewWidget {
container: gtk::Box,
image: gtk::Image,
episode: gtk::Box,
}
impl Default for EpisodesViewWidget {
fn default() -> Self {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 6);
EpisodesViewWidget {
container,
image,
episode: ep.container,
}
}
}
impl EpisodesViewWidget {
fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let pid = episode.podcast_id();
let ep = EpisodeWidget::new(episode, sender.clone());
let view = EpisodesViewWidget {
container,
image,
episode: ep.container,
};
view.init(pid);
view
}
fn init(&self, podcast_id: i32) {
if let Err(err) = self.set_cover(podcast_id) {
error!("Failed to set a cover: {}", err)
}
self.container.pack_start(&self.episode, true, true, 6);
}
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
let pd = Arc::new(dbqueries::get_podcast_cover_from_id(podcast_id)?);
set_image_from_path(&self.image, pd, 64)
}
}

View File

@ -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;

View File

@ -1,138 +0,0 @@
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::{Podcast, PodcastCoverQuery};
use hammond_data::dbqueries;
use app::Action;
use utils::{get_ignored_shows, set_image_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 ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
podcasts.into_iter().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: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn on_child_activate(child: &gtk::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());
let pd = Arc::new(pd.into());
if let Err(err) = self.set_cover(pd) {
error!("Failed to set a cover: {}", err)
}
}
fn set_cover(&self, pd: Arc<PodcastCoverQuery>) -> Result<(), Error> {
set_image_from_path(&self.cover, pd, 256)
}
}

View File

@ -1,372 +0,0 @@
use glib;
use gtk;
use gtk::prelude::*;
use failure::Error;
use humansize::FileSize;
use open;
use take_mut;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries;
use hammond_data::utils::get_download_folder;
use app::Action;
use manager;
use widgets::episode_states::*;
use std::cell::RefCell;
use std::ops::DerefMut;
use std::path::Path;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::Sender;
#[derive(Debug)]
pub struct EpisodeWidget {
pub container: gtk::Box,
date: DateMachine,
duration: DurationMachine,
title: Rc<RefCell<TitleMachine>>,
media: Arc<Mutex<MediaMachine>>,
}
impl Default for EpisodeWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
let container: gtk::Box = builder.get_object("episode_container").unwrap();
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
let download: gtk::Button = builder.get_object("download_button").unwrap();
let play: gtk::Button = builder.get_object("play_button").unwrap();
let cancel: gtk::Button = builder.get_object("cancel_button").unwrap();
let title: gtk::Label = builder.get_object("title_label").unwrap();
let date: gtk::Label = builder.get_object("date_label").unwrap();
let duration: gtk::Label = builder.get_object("duration_label").unwrap();
let local_size: gtk::Label = builder.get_object("local_size").unwrap();
let total_size: gtk::Label = builder.get_object("total_size").unwrap();
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
let date_machine = DateMachine::new(date, 0);
let dur_machine = DurationMachine::new(duration, separator1, None);
let title_machine = Rc::new(RefCell::new(TitleMachine::new(title, false)));
let media = MediaMachine::new(
play,
download,
progress,
cancel,
total_size,
local_size,
separator2,
prog_separator,
);
let media_machine = Arc::new(Mutex::new(media));
EpisodeWidget {
container,
title: title_machine,
duration: dur_machine,
date: date_machine,
media: media_machine,
}
}
}
impl EpisodeWidget {
pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let mut widget = EpisodeWidget::default();
widget.init(episode, sender);
widget
}
fn init(&mut self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
// Set the date label.
self.set_date(episode.epoch());
// Set the title label state.
self.set_title(&episode);
// Set the duaration label.
self.set_duration(episode.duration());
// Determine what the state of the media widgets should be.
if let Err(err) = self.determine_media_state(&episode) {
error!("Something went wrong determining the Media State.");
error!("Error: {}", err);
}
let episode = Arc::new(Mutex::new(episode));
self.connect_buttons(episode, sender);
}
fn connect_buttons(&self, episode: Arc<Mutex<EpisodeWidgetQuery>>, sender: Sender<Action>) {
let title = self.title.clone();
if let Ok(media) = self.media.lock() {
media.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.clone(), sender.clone()){
error!("Error: {}", err);
};
}
}));
media.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.");
}
}
}));
}
}
/// Determine the title state.
fn set_title(&mut self, episode: &EpisodeWidgetQuery) {
let mut machine = self.title.borrow_mut();
machine.set_title(episode.title());
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
}
/// Set the date label depending on the current time.
fn set_date(&mut self, epoch: i32) {
let machine = &mut self.date;
take_mut::take(machine, |date| date.determine_state(i64::from(epoch)));
}
/// Set the duration label.
fn set_duration(&mut self, seconds: Option<i32>) {
let machine = &mut self.duration;
take_mut::take(machine, |duration| duration.determine_state(seconds));
}
fn determine_media_state(&self, episode: &EpisodeWidgetQuery) -> Result<(), Error> {
let id = WidgetExt::get_name(&self.container)
.ok_or_else(|| format_err!("Failed to get widget Name"))?
.parse::<i32>()?;
let active_dl = || -> Result<Option<_>, Error> {
let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
Ok(m.get(&id).cloned())
}()?;
let mut lock = self.media.lock().map_err(|err| format_err!("{}", err))?;
take_mut::take(lock.deref_mut(), |media| {
media.determine_state(
episode.length(),
active_dl.is_some(),
episode.local_uri().is_some(),
)
});
// Show or hide the play/delete/download buttons upon widget initialization.
if let Some(prog) = active_dl {
lock.cancel_connect_clicked(prog.clone());
drop(lock);
// Setup a callback that will update the progress bar.
update_progressbar_callback(prog.clone(), self.media.clone(), id);
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), self.media.clone());
}
Ok(())
}
}
#[inline]
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
let download_fold = get_download_folder(&pd.title())?;
// Start a new download.
manager::add(ep.rowid(), download_fold, sender.clone())?;
// Update Views
sender.send(Action::RefreshEpisodesView)?;
sender.send(Action::RefreshWidgetIfVis)?;
Ok(())
}
#[inline]
fn on_play_bttn_clicked(
episode: &mut EpisodeWidgetQuery,
title: Rc<RefCell<TitleMachine>>,
sender: Sender<Action>,
) -> Result<(), Error> {
open_uri(episode.rowid())?;
episode.set_played_now()?;
let mut machine = title.try_borrow_mut()?;
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
sender.send(Action::RefreshEpisodesViewBGR)?;
Ok(())
}
fn open_uri(rowid: i32) -> Result<(), Error> {
let uri = dbqueries::get_episode_local_uri_from_id(rowid)?
.ok_or_else(|| format_err!("Expected Some found None."))?;
if Path::new(&uri).exists() {
info!("Opening {}", uri);
open::that(&uri)?;
} else {
bail!("File \"{}\" does not exist.", uri);
}
Ok(())
}
// Setup a callback that will update the progress bar.
#[inline]
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
fn update_progressbar_callback(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
episode_rowid: i32,
) {
timeout_add(
400,
clone!(prog, media => move || {
progress_bar_helper(prog.clone(), media.clone(), episode_rowid)
.unwrap_or(glib::Continue(false))
}),
);
}
#[inline]
#[allow(if_same_then_else)]
fn progress_bar_helper(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
episode_rowid: i32,
) -> Result<glib::Continue, Error> {
let (fraction, downloaded) = {
let m = prog.lock()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
(m.get_fraction(), m.get_downloaded())
};
// I hate floating points.
// Update the progress_bar.
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
// Update local_size label
let size = downloaded
.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))?;
let mut m = media.lock().unwrap();
m.update_progress(&size, fraction);
}
// info!("Fraction: {}", progress_bar.get_fraction());
// info!("Fraction: {}", fraction);
// Check if the download is still active
let active = {
let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
m.contains_key(&episode_rowid)
};
if (fraction >= 1.0) && (!fraction.is_nan()) {
Ok(glib::Continue(false))
} else if !active {
Ok(glib::Continue(false))
} else {
Ok(glib::Continue(true))
}
}
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
#[inline]
fn update_total_size_callback(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
) {
timeout_add(
500,
clone!(prog, media => move || {
total_size_helper(prog.clone(), media.clone()).unwrap_or(glib::Continue(true))
}),
);
}
#[inline]
fn total_size_helper(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
) -> Result<glib::Continue, Error> {
// Get the total_bytes.
let total_bytes = {
let m = prog.lock()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
m.get_total_size()
};
debug!("Total Size: {}", total_bytes);
if total_bytes != 0 {
// Update the total_size label
if let Ok(mut m) = media.lock() {
take_mut::take(m.deref_mut(), |machine| {
machine.set_size(Some(total_bytes as i32))
});
}
// Do not call again the callback
Ok(glib::Continue(false))
} else {
Ok(glib::Continue(true))
}
}
// fn on_delete_bttn_clicked(episode_id: i32) -> Result<(), Error> {
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
// }
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();
list.set_visible(true);
list.set_selection_mode(gtk::SelectionMode::None);
if episodes.is_empty() {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
list.add(&container);
return Ok(list);
}
episodes.into_iter().for_each(|ep| {
let widget = EpisodeWidget::new(ep, sender.clone());
list.add(&widget.container);
});
Ok(list)
}

View File

@ -1,917 +0,0 @@
// TODO: Things that should be done.
//
// * Wherever there's a function that take 2 or more arguments of the same type,
// eg: fn new(total_size: gtk::Label, local_size: gtk::Label ..)
// Wrap the types into Struct-tuples and imple deref so it won't be possible to pass
// the wrong argument to the wrong position.
use chrono;
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use humansize::{file_size_opts as size_opts, FileSize};
use std::sync::{Arc, Mutex};
use manager::Progress as OtherProgress;
lazy_static! {
pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
Arc::new(size_opts::FileSizeOpts {
divider: size_opts::Kilo::Binary,
units: size_opts::Kilo::Decimal,
decimal_places: 0,
decimal_zeroes: 0,
fixed_at: size_opts::FixedAt::No,
long_units: false,
space: true,
suffix: "",
allow_negative: false,
})
};
static ref NOW: DateTime<Utc> = Utc::now();
}
#[derive(Debug, Clone)]
pub struct UnInitialized;
#[derive(Debug, Clone)]
pub struct Shown;
#[derive(Debug, Clone)]
pub struct Hidden;
pub trait Visibility {}
impl Visibility for Shown {}
impl Visibility for Hidden {}
#[derive(Debug, Clone)]
pub struct Normal;
#[derive(Debug, Clone)]
pub struct GreyedOut;
#[derive(Debug, Clone)]
pub struct Title<S> {
title: gtk::Label,
state: S,
}
impl<S> Title<S> {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn set_title(&mut self, s: &str) {
self.title.set_text(s);
}
}
impl Title<Normal> {
fn new(title: gtk::Label) -> Self {
Title {
title,
state: Normal {},
}
}
}
impl From<Title<Normal>> for Title<GreyedOut> {
fn from(f: Title<Normal>) -> Self {
f.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
Title {
title: f.title,
state: GreyedOut {},
}
}
}
impl From<Title<GreyedOut>> for Title<Normal> {
fn from(f: Title<GreyedOut>) -> Self {
f.title
.get_style_context()
.map(|c| c.remove_class("dim-label"));
Title {
title: f.title,
state: Normal {},
}
}
}
#[derive(Debug, Clone)]
pub enum TitleMachine {
Normal(Title<Normal>),
GreyedOut(Title<GreyedOut>),
}
impl TitleMachine {
pub fn new(label: gtk::Label, is_played: bool) -> Self {
let m = TitleMachine::Normal(Title::<Normal>::new(label));
m.determine_state(is_played)
}
pub fn determine_state(self, is_played: bool) -> Self {
use self::TitleMachine::*;
match (self, is_played) {
(title @ Normal(_), false) => title,
(title @ GreyedOut(_), true) => title,
(Normal(val), true) => GreyedOut(val.into()),
(GreyedOut(val), false) => Normal(val.into()),
}
}
pub fn set_title(&mut self, s: &str) {
use self::TitleMachine::*;
match *self {
Normal(ref mut val) => val.set_title(s),
GreyedOut(ref mut val) => val.set_title(s),
}
}
}
#[derive(Debug, Clone)]
pub struct Usual;
#[derive(Debug, Clone)]
pub struct YearShown;
#[derive(Debug, Clone)]
pub struct Date<S> {
date: gtk::Label,
epoch: i64,
state: S,
}
impl<S> Date<S> {
fn into_usual(self, epoch: i64) -> Date<Usual> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: Usual {},
}
}
fn into_year_shown(self, epoch: i64) -> Date<YearShown> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: YearShown {},
}
}
}
impl Date<UnInitialized> {
fn new(date: gtk::Label, epoch: i64) -> Self {
let ts = Utc.timestamp(epoch, 0);
date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date,
epoch,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub enum DateMachine {
UnInitialized(Date<UnInitialized>),
Usual(Date<Usual>),
WithYear(Date<YearShown>),
}
impl DateMachine {
pub fn new(label: gtk::Label, epoch: i64) -> Self {
let m = DateMachine::UnInitialized(Date::<UnInitialized>::new(label, epoch));
m.determine_state(epoch)
}
pub fn determine_state(self, epoch: i64) -> Self {
use self::DateMachine::*;
let ts = Utc.timestamp(epoch, 0);
let is_old = !(NOW.year() == ts.year());
match (self, is_old) {
// Into Usual
(Usual(val), false) => Usual(val.into_usual(epoch)),
(WithYear(val), false) => Usual(val.into_usual(epoch)),
(UnInitialized(val), false) => Usual(val.into_usual(epoch)),
// Into Year Shown
(Usual(val), true) => WithYear(val.into_year_shown(epoch)),
(WithYear(val), true) => WithYear(val.into_year_shown(epoch)),
(UnInitialized(val), true) => WithYear(val.into_year_shown(epoch)),
}
}
}
#[derive(Debug, Clone)]
pub struct Duration<S: Visibility> {
// TODO: make duration and separator diff types
duration: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S: Visibility> Duration<S> {
// This needs a better name.
// TODO: make me mut
fn set_duration(&self, minutes: i64) {
self.duration.set_text(&format!("{} min", minutes));
}
}
impl Duration<Hidden> {
fn new(duration: gtk::Label, separator: gtk::Label) -> Self {
duration.hide();
separator.hide();
Duration {
duration,
separator,
state: Hidden {},
}
}
}
impl From<Duration<Hidden>> for Duration<Shown> {
fn from(f: Duration<Hidden>) -> Self {
f.duration.show();
f.separator.show();
Duration {
duration: f.duration,
separator: f.separator,
state: Shown {},
}
}
}
impl From<Duration<Shown>> for Duration<Hidden> {
fn from(f: Duration<Shown>) -> Self {
f.duration.hide();
f.separator.hide();
Duration {
duration: f.duration,
separator: f.separator,
state: Hidden {},
}
}
}
#[derive(Debug, Clone)]
pub enum DurationMachine {
Hidden(Duration<Hidden>),
Shown(Duration<Shown>),
}
impl DurationMachine {
pub fn new(duration: gtk::Label, separator: gtk::Label, seconds: Option<i32>) -> Self {
let m = DurationMachine::Hidden(Duration::<Hidden>::new(duration, separator));
m.determine_state(seconds)
}
pub fn determine_state(self, seconds: Option<i32>) -> Self {
match (self, seconds) {
(d @ DurationMachine::Hidden(_), None) => d,
(DurationMachine::Shown(val), None) => DurationMachine::Hidden(val.into()),
(DurationMachine::Hidden(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val)
} else {
val.set_duration(minutes);
DurationMachine::Shown(val.into())
}
}
(DurationMachine::Shown(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val.into())
} else {
val.set_duration(minutes);
DurationMachine::Shown(val)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct Size<S> {
size: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S> Size<S> {
fn set_size(self, s: &str) -> Size<Shown> {
self.size.set_text(s);
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_shown(self) -> Size<Shown> {
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
fn into_hidden(self) -> Size<Hidden> {
self.size.hide();
self.separator.hide();
Size {
size: self.size,
separator: self.separator,
state: Hidden {},
}
}
}
impl Size<UnInitialized> {
fn new(size: gtk::Label, separator: gtk::Label) -> Self {
size.hide();
separator.hide();
Size {
size,
separator,
state: UnInitialized {},
}
}
}
// pub trait Playable {}
// impl Playable for Download {}
// impl Playable for Play {}
#[derive(Debug, Clone)]
pub struct Download;
#[derive(Debug, Clone)]
pub struct Play;
#[derive(Debug, Clone)]
// FIXME: Needs better name.
// Should each button also has it's own type and machine?
pub struct DownloadPlay<S> {
play: gtk::Button,
download: gtk::Button,
state: S,
}
impl<S> DownloadPlay<S> {
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_playable(self) -> DownloadPlay<Play> {
self.play.show();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Play {},
}
}
fn into_fetchable(self) -> DownloadPlay<Download> {
self.play.hide();
self.download.show();
DownloadPlay {
play: self.play,
download: self.download,
state: Download {},
}
}
fn into_hidden(self) -> DownloadPlay<Hidden> {
self.play.hide();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Hidden {},
}
}
fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.download.connect_clicked(f)
}
fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.play.connect_clicked(f)
}
}
impl DownloadPlay<UnInitialized> {
fn new(play: gtk::Button, download: gtk::Button) -> Self {
play.hide();
download.hide();
DownloadPlay {
play,
download,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Progress<S> {
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
state: S,
}
impl<S> Progress<S> {
fn into_shown(self) -> Progress<Shown> {
self.bar.show();
self.cancel.show();
self.local_size.show();
self.prog_separator.show();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Shown {},
}
}
fn into_hidden(self) -> Progress<Hidden> {
self.bar.hide();
self.cancel.hide();
self.local_size.hide();
self.prog_separator.hide();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Hidden {},
}
}
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.local_size.set_text(local_size);
self.bar.set_fraction(fraction);
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
self.cancel.connect_clicked(move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
})
}
}
impl Progress<UnInitialized> {
fn new(
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
bar.hide();
cancel.hide();
local_size.hide();
prog_separator.hide();
Progress {
bar,
cancel,
local_size,
prog_separator,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Media<X, Y, Z> {
dl: DownloadPlay<X>,
size: Size<Y>,
progress: Progress<Z>,
}
type New<Y> = Media<Download, Y, Hidden>;
type Playable<Y> = Media<Play, Y, Hidden>;
type InProgress = Media<Hidden, Shown, Shown>;
type MediaUnInitialized = Media<UnInitialized, UnInitialized, UnInitialized>;
impl From<New<Shown>> for InProgress {
fn from(f: New<Shown>) -> Self {
f.into_progress()
}
}
impl From<New<Hidden>> for InProgress {
fn from(f: New<Hidden>) -> Self {
f.into_progress()
}
}
impl From<Playable<Shown>> for InProgress {
fn from(f: Playable<Shown>) -> Self {
f.into_progress()
}
}
impl From<Playable<Hidden>> for InProgress {
fn from(f: Playable<Hidden>) -> Self {
f.into_progress()
}
}
impl<Y: Visibility> From<Playable<Y>> for New<Y> {
fn from(f: Playable<Y>) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size,
progress: f.progress,
}
}
}
impl<Y: Visibility> From<New<Y>> for Playable<Y> {
fn from(f: New<Y>) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size,
progress: f.progress,
}
}
}
impl From<MediaUnInitialized> for New<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl From<MediaUnInitialized> for Playable<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl<X, Y, Z> Media<X, Y, Z> {
fn set_size(self, s: &str) -> Media<X, Shown, Z> {
Media {
dl: self.dl,
size: self.size.set_size(s),
progress: self.progress,
}
}
fn hide_size(self) -> Media<X, Hidden, Z> {
Media {
dl: self.dl,
size: self.size.into_hidden(),
progress: self.progress,
}
}
fn into_new(self, size: &str) -> New<Shown> {
Media {
dl: self.dl.into_fetchable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
fn into_playable(self, size: &str) -> Playable<Shown> {
Media {
dl: self.dl.into_playable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
}
impl<X, Z> Media<X, Shown, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.into_shown(),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, Hidden, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, UnInitialized, Z> {
fn into_progress(self, size: Option<String>) -> InProgress {
if let Some(s) = size {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size(&s),
progress: self.progress.into_shown(),
}
} else {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
}
impl InProgress {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.progress.update_progress(local_size, fraction)
}
}
#[derive(Debug, Clone)]
pub enum ButtonsState {
New(Media<Download, Shown, Hidden>),
NewWithoutSize(Media<Download, Hidden, Hidden>),
Playable(Media<Play, Shown, Hidden>),
PlayableWithoutSize(Media<Play, Hidden, Hidden>),
}
impl ButtonsState {
pub fn determine_state(self, size: Option<String>, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
match (self, size, is_downloaded) {
// From whatever to New
(New(m), Some(s), false) => New(m.into_new(&s)),
(Playable(m), Some(s), false) => New(m.into_new(&s)),
(NewWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
// From whatever to Playable
(New(m), Some(s), true) => Playable(m.into_playable(&s)),
(Playable(m), Some(s), true) => Playable(m.into_playable(&s)),
(NewWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
(PlayableWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
// From whatever to NewWithoutSize
(New(m), None, false) => NewWithoutSize(m.hide_size()),
(Playable(m), None, false) => NewWithoutSize(Media::from(m).hide_size()),
(b @ NewWithoutSize(_), None, false) => b,
(PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into()),
// From whatever to PlayableWithoutSize
(New(m), None, true) => PlayableWithoutSize(Media::from(m).hide_size()),
(Playable(m), None, true) => PlayableWithoutSize(m.hide_size()),
(NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into()),
(b @ PlayableWithoutSize(_), None, true) => b,
// _ => unimplemented!()
}
}
fn into_progress(self) -> InProgress {
use self::ButtonsState::*;
match self {
New(m) => m.into(),
Playable(m) => m.into(),
NewWithoutSize(m) => m.into(),
PlayableWithoutSize(m) => m.into(),
}
}
fn set_size(self, size: Option<String>) -> Self {
use self::ButtonsState::*;
match (self, size) {
(New(m), Some(s)) => New(m.set_size(&s)),
(New(m), None) => NewWithoutSize(m.hide_size()),
(Playable(m), Some(s)) => Playable(m.set_size(&s)),
(Playable(m), None) => PlayableWithoutSize(m.hide_size()),
(bttn @ NewWithoutSize(_), None) => bttn,
(bttn @ PlayableWithoutSize(_), None) => bttn,
(NewWithoutSize(m), Some(s)) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s)) => Playable(m.into_playable(&s)),
}
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.download_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.download_connect_clicked(f),
Playable(ref val) => val.dl.download_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.play_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.play_connect_clicked(f),
Playable(ref val) => val.dl.play_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.play_connect_clicked(f),
}
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.progress.cancel_connect_clicked(prog),
NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
Playable(ref val) => val.progress.cancel_connect_clicked(prog),
PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
}
#[derive(Debug, Clone)]
pub enum MediaMachine {
UnInitialized(Media<UnInitialized, UnInitialized, UnInitialized>),
Initialized(ButtonsState),
InProgress(Media<Hidden, Shown, Shown>),
}
impl MediaMachine {
#[cfg_attr(feature = "cargo-clippy", allow(too_many_arguments))]
pub fn new(
play: gtk::Button,
download: gtk::Button,
bar: gtk::ProgressBar,
cancel: gtk::Button,
total_size: gtk::Label,
local_size: gtk::Label,
separator: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
let dl = DownloadPlay::<UnInitialized>::new(play, download);
let progress = Progress::<UnInitialized>::new(bar, cancel, local_size, prog_separator);
let size = Size::<UnInitialized>::new(total_size, separator);
MediaMachine::UnInitialized(Media { dl, progress, size })
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.download_connect_clicked(f),
Initialized(ref val) => val.download_connect_clicked(f),
InProgress(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.play_connect_clicked(f),
Initialized(ref val) => val.play_connect_clicked(f),
InProgress(ref val) => val.dl.play_connect_clicked(f),
}
}
pub fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.progress.cancel_connect_clicked(prog),
Initialized(ref val) => val.cancel_connect_clicked(prog),
InProgress(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
pub fn determine_state(self, bytes: Option<i32>, is_active: bool, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
use self::MediaMachine::*;
match (self, size_helper(bytes), is_downloaded, is_active) {
(UnInitialized(m), s, _, true) => InProgress(m.into_progress(s)),
// Into New
(UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
(UnInitialized(m), None, false, false) => Initialized(NewWithoutSize(m.into())),
// Into Playable
(UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
(UnInitialized(m), None, true, false) => Initialized(PlayableWithoutSize(m.into())),
(Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)),
(Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()),
(i @ InProgress(_), _, _, _) => i,
}
}
pub fn set_size(self, bytes: Option<i32>) -> Self {
use self::MediaMachine::*;
let size = size_helper(bytes);
match (self, size) {
(Initialized(bttn), s) => Initialized(bttn.set_size(s)),
(InProgress(val), Some(s)) => InProgress(val.set_size(&s)),
(n @ InProgress(_), None) => n,
(n @ UnInitialized(_), _) => n,
}
}
pub fn update_progress(&mut self, local_size: &str, fraction: f64) {
use self::MediaMachine::*;
match *self {
Initialized(_) => (),
UnInitialized(_) => (),
InProgress(ref mut val) => val.update_progress(local_size, fraction),
}
}
}
#[inline]
fn size_helper(bytes: Option<i32>) -> Option<String> {
let s = bytes?;
if s == 0 {
return None;
}
s.file_size(SIZE_OPTS.clone()).ok()
}

View File

@ -1,7 +0,0 @@
mod show;
mod episode;
mod episode_states;
pub use self::episode::EpisodeWidget;
pub use self::show::ShowWidget;
pub use self::show::mark_all_watched;

View File

@ -1,191 +0,0 @@
use failure::Error;
// use glib;
use gtk;
use gtk::prelude::*;
use html2pango::markup_from_raw;
use open;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use app::Action;
use utils::set_image_from_path;
use widgets::episode::episodes_listbox;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct ShowWidget {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
cover: gtk::Image,
description: gtk::Label,
link: gtk::Button,
settings: gtk::MenuButton,
unsub: gtk::Button,
episodes: gtk::Frame,
}
impl Default for ShowWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let episodes: gtk::Frame = builder.get_object("episodes").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap();
let description: gtk::Label = builder.get_object("description").unwrap();
let unsub: gtk::Button = builder.get_object("unsub_button").unwrap();
let link: gtk::Button = builder.get_object("link_button").unwrap();
let settings: gtk::MenuButton = builder.get_object("settings_button").unwrap();
ShowWidget {
container,
scrolled_window,
cover,
description,
unsub,
link,
settings,
episodes,
}
}
}
impl ShowWidget {
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> ShowWidget {
let pdw = ShowWidget::default();
pdw.init(pd, sender);
pdw
}
pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
WidgetExt::set_name(&self.container, &pd.id().to_string());
self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| {
if let Err(err) = on_unsub_button_clicked(pd.clone(), bttn, sender.clone()) {
error!("Error: {}", err);
}
}));
self.setup_listbox(&pd, sender.clone());
self.set_description(pd.description());
if let Err(err) = self.set_cover(pd.clone()) {
error!("Failed to set a cover: {}", err)
}
let link = pd.link().to_owned();
self.link.set_tooltip_text(Some(link.as_str()));
self.link.connect_clicked(move |_| {
info!("Opening link: {}", &link);
if let Err(err) = open::that(&link) {
error!("Failed to open link: {}", &link);
error!("Error: {}", err);
}
});
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
let episodes = self.episodes.clone();
mark_all.connect_clicked(clone!(pd, sender => move |_| {
on_played_button_clicked(
pd.clone(),
&episodes,
sender.clone()
)
}));
self.settings.set_popover(&show_menu);
}
/// Populate the listbox with the shows episodes.
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
let listbox = episodes_listbox(pd, sender.clone());
listbox.ok().map(|l| self.episodes.add(&l));
}
/// Set the show cover.
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
set_image_from_path(&self.cover, Arc::new(pd.into()), 128)
}
/// Set the descripton text.
fn set_description(&self, text: &str) {
self.description.set_markup(&markup_from_raw(text));
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn on_unsub_button_clicked(
pd: Arc<Podcast>,
unsub_button: &gtk::Button,
sender: Sender<Action>,
) -> Result<(), Error> {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.hide();
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(())
}
fn on_played_button_clicked(pd: Arc<Podcast>, episodes: &gtk::Frame, 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)).unwrap();
}
pub fn mark_all_watched(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?;
sender.send(Action::RefreshWidgetIfVis)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
}
// 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: &gtk::Frame) -> Option<()> {
let listbox = episodes
.get_children()
.remove(0)
.downcast::<gtk::ListBox>()
.ok()?;
let children = listbox.get_children();
for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let foo = container
.get_children()
.remove(0)
.downcast::<gtk::Box>()
.ok()?;
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let baz = bar.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let title = baz.get_children().remove(0).downcast::<gtk::Label>().ok()?;
title.get_style_context().map(|c| c.add_class("dim-label"));
}
Some(())
}

View File

@ -1,38 +1,90 @@
# Adatped from:
# https://gitlab.gnome.org/danigm/fractal/blob/6e2911f9d2353c99a18a6c19fab7f903c4bbb431/meson.build
project(
'hammond', 'rust',
version: '0.3.1',
'gnome-podcasts', 'rust',
version: '0.4.7',
license: 'GPLv3',
)
hammond_version = meson.project_version()
version_array = hammond_version.split('.')
hammond_major_version = version_array[0].to_int()
hammond_minor_version = version_array[1].to_int()
hammond_version_micro = version_array[2].to_int()
dependency('sqlite3', version: '>= 3.20')
dependency('openssl', version: '>= 1.0')
dependency('dbus-1')
hammond_prefix = get_option('prefix')
hammond_bindir = join_paths(hammond_prefix, get_option('bindir'))
dependency('glib-2.0', version: '>= 2.56')
dependency('gio-2.0', version: '>= 2.56')
dependency('gdk-pixbuf-2.0')
dependency('gtk+-3.0', version: '>= 3.24.11')
dependency('libhandy-0.0', version: '>= 0.0.13')
dependency('gstreamer-1.0', version: '>= 1.16')
dependency('gstreamer-base-1.0', version: '>= 1.16')
dependency('gstreamer-audio-1.0', version: '>= 1.16')
dependency('gstreamer-video-1.0', version: '>= 1.16')
dependency('gstreamer-player-1.0', version: '>= 1.16')
dependency('gstreamer-plugins-base-1.0', version: '>= 1.16')
dependency('gstreamer-plugins-bad-1.0', version: '>= 1.16')
dependency('gstreamer-bad-audio-1.0', version: '>= 1.16')
cargo = find_program('cargo', required: true)
gresource = find_program('glib-compile-resources', required: true)
gschemas = find_program('glib-compile-schemas', required: true)
if get_option('profile') == 'development'
profile = '.Devel'
vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip()
if vcs_tag == ''
version_suffix = '-devel'
else
version_suffix = '-@0@'.format (vcs_tag)
endif
else
profile = ''
version_suffix = ''
endif
podcast_toml = files(
'Cargo.toml',
'Cargo.lock',
'podcasts-data/Cargo.toml',
'podcasts-downloader/Cargo.toml',
'podcasts-gtk/Cargo.toml',
)
application_id = 'org.gnome.Podcasts@0@'.format(profile)
i18n = import('i18n')
gnome = import('gnome')
subdir('podcasts-gtk/po')
podir = join_paths (meson.source_root (), 'podcasts-gtk', 'po')
podcasts_version = meson.project_version()
podcasts_prefix = get_option('prefix')
podcasts_bindir = join_paths(podcasts_prefix, get_option('bindir'))
podcasts_localedir = join_paths(podcasts_prefix, get_option('localedir'))
podcasts_conf = configuration_data()
podcasts_conf.set('appid', application_id)
podcasts_conf.set('bindir', podcasts_bindir)
datadir = get_option('datadir')
icondir = join_paths(datadir, 'icons')
subdir('hammond-gtk/resources')
subdir('podcasts-gtk/resources')
cargo = find_program('cargo', required: false)
gresource = find_program('glib-compile-resources', required: false)
cargo_vendor = find_program('cargo-vendor', required: false)
cargo_script = find_program('scripts/cargo.sh')
test_script = find_program('scripts/test.sh')
cargo_release = custom_target('cargo-build',
build_by_default: true,
build_always: true,
output: ['hammond'],
install: true,
install_dir: hammond_bindir,
command: [cargo_script, '@CURRENT_SOURCE_DIR@', '@OUTPUT@'])
subdir('podcasts-data/src')
subdir('podcasts-downloader/src')
subdir('podcasts-gtk/src')
run_target('release', command: ['scripts/release.sh',
meson.project_name() + '-' + hammond_version
])
meson.add_dist_script(
'scripts/dist-vendor.sh',
meson.source_root(),
join_paths(meson.build_root(), 'meson-dist', meson.project_name() + '-' + podcasts_version)
)
test(
'cargo-test',
test_script,
args: meson.build_root(),
workdir: meson.source_root(),
timeout: 3000
)

9
meson_options.txt Normal file
View File

@ -0,0 +1,9 @@
option (
'profile',
type: 'combo',
choices: [
'default',
'development'
],
value: 'default'
)

View File

@ -1,47 +0,0 @@
{
"app-id" : "org.gnome.Hammond",
"runtime" : "org.gnome.Platform",
"runtime-version" : "master",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
],
"command" : "hammond",
"tags" : [
"nightly"
],
"desktop-file-name-prefix" : "(Nightly) ",
"finish-args" : [
"--filesystem=xdg-run/dconf",
"--filesystem=~/.config/dconf:ro",
"--talk-name=ca.desrt.dconf",
"--env=DCONF_USER_CONFIG_DIR=.config/dconf",
"--share=network",
"--share=ipc",
"--socket=x11",
"--socket=wayland",
"--talk-name=org.freedesktop.Desktop"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin",
"build-args" : [
"--share=network"
],
"env" : {
"CARGO_HOME" : "/run/build/Hammond/cargo"
}
},
"modules" : [
{
"name" : "hammond",
"buildsystem" : "meson",
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/alatiera/Hammond.git",
"branch" : "master"
}
]
}
]
}

View File

@ -0,0 +1,72 @@
{
"app-id" : "org.gnome.Podcasts.Devel",
"runtime" : "org.gnome.Platform",
"runtime-version" : "3.36",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
],
"command" : "gnome-podcasts",
"tags" : [
"nightly"
],
"finish-args" : [
"--share=network",
"--share=ipc",
"--socket=x11",
"--socket=fallback-x11",
"--socket=wayland",
"--socket=pulseaudio",
"--env=USE_PLAYBING3=1"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin",
"build-args" : [
"--share=network"
],
"env" : {
"CARGO_HOME" : "/run/build/Podcasts/cargo",
"RUSTFLAGS" : "",
"RUST_BACKTRACE" : "1"
}
},
"modules" : [
{
"name" : "libhandy",
"buildsystem" : "meson",
"config-opts" : [
"-Dintrospection=disabled",
"-Dgtk_doc=false",
"-Dtests=false",
"-Dexamples=false",
"-Dvapi=false",
"-Dglade_catalog=disabled"
],
"cleanup" : [
"/include",
"/lib/pkgconfig"
],
"sources" : [
{
"type" : "git",
"url" : "https://source.puri.sm/Librem5/libhandy.git",
"tag" : "v0.0.13"
}
]
},
{
"name" : "gnome-podcasts",
"buildsystem" : "meson",
"builddir" : "true",
"config-opts" : [
"-Dprofile=development"
],
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/World/podcasts.git"
}
]
}
]
}

69
org.gnome.Podcasts.json Normal file
View File

@ -0,0 +1,69 @@
{
"app-id" : "org.gnome.Podcasts",
"runtime" : "org.gnome.Platform",
"runtime-version" : "3.36",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
],
"command" : "gnome-podcasts",
"tags" : [
"nightly"
],
"desktop-file-name-suffix" : " ☢️",
"finish-args" : [
"--share=network",
"--share=ipc",
"--socket=x11",
"--socket=fallback-x11",
"--socket=wayland",
"--socket=pulseaudio",
"--env=USE_PLAYBING3=1"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin",
"build-args" : [
"--share=network"
],
"env" : {
"CARGO_HOME" : "/run/build/Podcasts/cargo",
"RUST_BACKTRACE" : "1"
}
},
"modules" : [
{
"name" : "libhandy",
"buildsystem" : "meson",
"config-opts" : [
"-Dintrospection=disabled",
"-Dgtk_doc=false",
"-Dtests=false",
"-Dexamples=false",
"-Dvapi=false",
"-Dglade_catalog=disabled"
],
"cleanup" : [
"/include",
"/lib/pkgconfig"
],
"sources" : [
{
"type" : "git",
"url" : "https://source.puri.sm/Librem5/libhandy.git",
"tag" : "v0.0.13"
}
]
},
{
"name" : "gnome-podcasts",
"builddir" : "true",
"buildsystem" : "meson",
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/World/podcasts.git"
}
]
}
]
}

42
podcasts-data/Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[package]
authors = ["Jordan Petridis <jpetridis@gnome.org>"]
name = "podcasts-data"
version = "0.1.0"
edition = "2018"
[dependencies]
ammonia = "3.1.0"
chrono = "0.4.11"
derive_builder = "0.9.0"
lazy_static = "1.4.0"
log = "0.4.8"
rayon = "1.3.1"
rfc822_sanitizer = "0.3.3"
rss = "1.9.0"
url = "2.1.1"
xdg = "2.2.0"
xml-rs = "0.8.3"
futures = "0.1.29"
hyper = "0.12.35"
http = "0.1.19"
tokio = "0.1.22"
hyper-tls = "0.3.2"
native-tls = "0.2.3"
num_cpus = "1.13.0"
failure = "0.1.8"
failure_derive = "0.1.8"
base64 = "0.12.2"
[dependencies.diesel]
features = ["sqlite", "r2d2"]
version = "1.4.5"
[dependencies.diesel_migrations]
features = ["sqlite"]
version = "1.4.0"
[dev-dependencies]
rand = "0.7.2"
tempdir = "0.3.7"
pretty_assertions = "0.6.1"
maplit = "1.0.2"

View File

@ -0,0 +1,6 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
patch_file = "src/schema.patch"

View File

@ -0,0 +1,53 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, favorite, archive)
SELECT title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, 0, 0
FROM old_table;
Drop table old_table;
ALTER TABLE podcast RENAME TO old_table;
CREATE TABLE `podcast` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
`title` TEXT NOT NULL,
`link` TEXT NOT NULL,
`description` TEXT NOT NULL,
`image_uri` TEXT,
`source_id` INTEGER NOT NULL UNIQUE,
`favorite` INTEGER NOT NULL DEFAULT 0,
`archive` INTEGER NOT NULL DEFAULT 0,
`always_dl` INTEGER NOT NULL DEFAULT 0
);
INSERT INTO podcast (
id,
title,
link,
description,
image_uri,
source_id
) SELECT id,
title,
link,
description,
image_uri,
source_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,66 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
podcast_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
podcast_id
FROM old_table;
Drop table old_table;
ALTER TABLE podcast RENAME TO old_table;
CREATE TABLE `podcast` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
`title` TEXT NOT NULL,
`link` TEXT NOT NULL,
`description` TEXT NOT NULL,
`image_uri` TEXT,
`source_id` INTEGER NOT NULL UNIQUE
);
INSERT INTO podcast (
id,
title,
link,
description,
image_uri,
source_id
) SELECT id,
title,
link,
description,
image_uri,
source_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,40 @@
ALTER TABLE episodes RENAME TO old_table;
ALTER TABLE shows RENAME TO podcast;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
podcast_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
show_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,40 @@
ALTER TABLE episode RENAME TO old_table;
ALTER TABLE podcast RENAME TO shows;
CREATE TABLE episodes (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
show_id INTEGER NOT NULL,
PRIMARY KEY (title, show_id)
);
INSERT INTO episodes (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
show_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
podcast_id
FROM old_table;
Drop table old_table;

View File

@ -1,4 +1,25 @@
// database.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Database Setup. This is only public to help with some unit tests.
// Diesel embed_migrations! triggers the lint
#![allow(unused_imports)]
use diesel::prelude::*;
use diesel::r2d2;
@ -7,10 +28,10 @@ use diesel::r2d2::ConnectionManager;
use std::io;
use std::path::PathBuf;
use errors::DataError;
use crate::errors::DataError;
#[cfg(not(test))]
use xdg_dirs;
use crate::xdg_dirs;
type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
@ -22,16 +43,16 @@ lazy_static! {
#[cfg(not(test))]
lazy_static! {
static ref DB_PATH: PathBuf = xdg_dirs::HAMMOND_XDG.place_data_file("hammond.db").unwrap();
static ref DB_PATH: PathBuf = xdg_dirs::PODCASTS_XDG
.place_data_file("podcasts.db")
.unwrap();
}
#[cfg(test)]
extern crate tempdir;
#[cfg(test)]
lazy_static! {
static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("hammond_unit_test").unwrap() };
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
pub(crate) static ref TEMPDIR: tempdir::TempDir =
{ tempdir::TempDir::new("podcasts_unit_test").unwrap() };
static ref DB_PATH: PathBuf = TEMPDIR.path().join("podcasts.db");
}
/// Get an r2d2 `SqliteConnection`.
@ -62,12 +83,12 @@ fn run_migration_on(connection: &SqliteConnection) -> Result<(), DataError> {
/// Reset the database into a clean state.
// Test share a Temp file db.
#[allow(dead_code)]
#[cfg(test)]
pub fn truncate_db() -> Result<(), DataError> {
let db = connection();
let con = db.get()?;
con.execute("DELETE FROM episode")?;
con.execute("DELETE FROM podcast")?;
con.execute("DELETE FROM episodes")?;
con.execute("DELETE FROM shows")?;
con.execute("DELETE FROM source")?;
Ok(())
}

View File

@ -0,0 +1,492 @@
// dbqueries.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Random CRUD helper functions.
use chrono::prelude::*;
use diesel::prelude::*;
use diesel;
use diesel::dsl::exists;
use diesel::select;
use crate::database::connection;
use crate::errors::DataError;
use crate::models::*;
pub fn get_sources() -> Result<Vec<Source>, DataError> {
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.order((http_etag.asc(), last_modified.asc()))
.load::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcasts() -> Result<Vec<Show>, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
shows
.order(title.asc())
.load::<Show>(&con)
.map_err(From::from)
}
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Show>, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
shows
.order(title.asc())
.filter(id.ne_all(filter_ids))
.load::<Show>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((rowid, local_uri, played))
.filter(local_uri.is_not_null())
.load::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> {
// use schema::episodes::dsl::*;
// let db = connection();
// let con = db.get()?;
// episodes
// .filter(played.is_not_null())
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((rowid, local_uri, played))
.filter(played.is_not_null())
.load::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.filter(rowid.eq(ep_id))
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetModel, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
))
.filter(rowid.eq(ep_id))
.get_result::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.filter(rowid.eq(ep_id))
.select(local_uri)
.get_result::<Option<String>>(&con)
.map_err(From::from)
}
pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32],
limit: u32,
) -> Result<Vec<EpisodeWidgetModel>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
);
episodes
.select(columns)
.order(epoch.desc())
.filter(show_id.ne_all(filter_ids))
.limit(i64::from(limit))
.load::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_id(pid: i32) -> Result<Show, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
shows
.filter(id.eq(pid))
.get_result::<Show>(&con)
.map_err(From::from)
}
pub fn get_podcast_cover_from_id(pid: i32) -> Result<ShowCoverModel, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
shows
.select((id, title, image_uri))
.filter(id.eq(pid))
.get_result::<ShowCoverModel>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
Episode::belonging_to(parent)
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes_count(parent: &Show) -> 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: &Show) -> Result<Vec<EpisodeWidgetModel>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
);
episodes
.select(columns)
.filter(show_id.eq(parent.id()))
.order(epoch.desc())
.load::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_pd_unplayed_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
Episode::belonging_to(parent)
.filter(played.is_null())
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
// pub(crate) fn get_pd_episodes_limit(parent: &Show, limit: u32) ->
// Result<Vec<Episode>, DataError> { use schema::episodes::dsl::*;
// let db = connection();
// let con = db.get()?;
// Episode::belonging_to(parent)
// .order(epoch.desc())
// .limit(i64::from(limit))
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub fn get_source_from_uri(uri_: &str) -> Result<Source, DataError> {
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.filter(uri.eq(uri_))
.get_result::<Source>(&con)
.map_err(From::from)
}
pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.filter(id.eq(id_))
.get_result::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_source_id(sid: i32) -> Result<Show, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
shows
.filter(source_id.eq(sid))
.get_result::<Show>(&con)
.map_err(From::from)
}
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.filter(title.eq(title_))
.filter(show_id.eq(pid))
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub(crate) fn get_episode_minimal_from_pk(
title_: &str,
pid: i32,
) -> Result<EpisodeMinimal, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((rowid, title, uri, epoch, length, duration, guid, show_id))
.filter(title.eq(title_))
.filter(show_id.eq(pid))
.get_result::<EpisodeMinimal>(&con)
.map_err(From::from)
}
#[cfg(test)]
pub(crate) fn get_episode_cleaner_from_pk(
title_: &str,
pid: i32,
) -> Result<EpisodeCleanerModel, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((rowid, local_uri, played))
.filter(title.eq(title_))
.filter(show_id.eq(pid))
.get_result::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
pub(crate) fn remove_feed(pd: &Show) -> Result<(), DataError> {
let db = connection();
let con = db.get()?;
con.transaction(|| {
delete_source(&con, pd.source_id())?;
delete_podcast(&con, pd.id())?;
delete_podcast_episodes(&con, pd.id())?;
info!("Feed removed from the Database.");
Ok(())
})
}
fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
use crate::schema::source::dsl::*;
diesel::delete(source.filter(id.eq(source_id))).execute(con)
}
fn delete_podcast(con: &SqliteConnection, show_id: i32) -> QueryResult<usize> {
use crate::schema::shows::dsl::*;
diesel::delete(shows.filter(id.eq(show_id))).execute(con)
}
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
use crate::schema::episodes::dsl::*;
diesel::delete(episodes.filter(show_id.eq(parent_id))).execute(con)
}
pub fn source_exists(url: &str) -> Result<bool, DataError> {
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(source.filter(uri.eq(url))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(shows.filter(source_id.eq(source_id_))))
.get_result(&con)
.map_err(From::from)
}
#[cfg_attr(rustfmt, rustfmt_skip)]
pub(crate) fn episode_exists(title_: &str, show_id_: i32) -> Result<bool, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episodes.filter(show_id.eq(show_id_)).filter(title.eq(title_))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `episodes table contains any rows
///
/// Return true if `episodes` table is populated.
pub fn is_episodes_populated(filter_show_ids: &[i32]) -> Result<bool, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episodes.filter(show_id.ne_all(filter_show_ids))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `shows` table contains any rows
///
/// Return true if `shows` table is populated.
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(shows.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `source` table contains any rows
///
/// Return true if `source` table is populated.
pub fn is_source_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(source.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(episodes)
.values(eps)
.execute(&*con)
.map_err(From::from)
.map(|_| ())
}
pub fn update_none_to_played_now(parent: &Show) -> Result<usize, DataError> {
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
let epoch_now = Utc::now().timestamp() as i32;
con.transaction(|| {
diesel::update(Episode::belonging_to(parent).filter(played.is_null()))
.set(played.eq(Some(epoch_now)))
.execute(&con)
.map_err(From::from)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::*;
use crate::pipeline;
use failure::Error;
#[test]
fn test_update_none_to_played_now() -> Result<(), Error> {
truncate_db()?;
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url)?;
let id = source.id();
pipeline::run(vec![source])?;
let pd = get_podcast_from_source_id(id)?;
let eps_num = get_pd_unplayed_episodes(&pd)?.len();
assert_ne!(eps_num, 0);
update_none_to_played_now(&pd)?;
let eps_num2 = get_pd_unplayed_episodes(&pd)?.len();
assert_eq!(eps_num2, 0);
Ok(())
}
}

125
podcasts-data/src/errors.rs Normal file
View File

@ -0,0 +1,125 @@
// errors.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use diesel;
use diesel::r2d2;
use diesel_migrations::RunMigrationsError;
use http;
use hyper;
use native_tls;
use rss;
use url;
use xml;
use std::io;
use crate::models::Source;
#[fail(
display = "Request to {} returned {}. Context: {}",
url, status_code, context
)]
#[derive(Fail, Debug)]
pub struct HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
}
impl HttpStatusError {
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
HttpStatusError {
url,
status_code: code,
context,
}
}
}
#[derive(Fail, Debug)]
pub enum DataError {
#[fail(display = "SQL Query failed: {}", _0)]
DieselResultError(#[cause] diesel::result::Error),
#[fail(display = "Database Migration error: {}", _0)]
DieselMigrationError(#[cause] RunMigrationsError),
#[fail(display = "R2D2 error: {}", _0)]
R2D2Error(#[cause] r2d2::Error),
#[fail(display = "R2D2 Pool error: {}", _0)]
R2D2PoolError(#[cause] r2d2::PoolError),
#[fail(display = "Hyper Error: {}", _0)]
HyperError(#[cause] hyper::Error),
#[fail(display = "ToStr Error: {}", _0)]
HttpToStr(#[cause] http::header::ToStrError),
#[fail(display = "Failed to parse a url: {}", _0)]
UrlError(#[cause] url::ParseError),
#[fail(display = "TLS Error: {}", _0)]
TLSError(#[cause] native_tls::Error),
#[fail(display = "IO Error: {}", _0)]
IOError(#[cause] io::Error),
#[fail(display = "RSS Error: {}", _0)]
RssError(#[cause] rss::Error),
#[fail(display = "XML Reader Error: {}", _0)]
XmlReaderError(#[cause] xml::reader::Error),
#[fail(display = "Error: {}", _0)]
Bail(String),
#[fail(display = "{}", _0)]
HttpStatusGeneral(HttpStatusError),
#[fail(display = "Source redirects to a new url")]
FeedRedirect(Source),
#[fail(display = "Feed is up to date")]
FeedNotModified(Source),
#[fail(
display = "Error occurred while Parsing an Episode. Reason: {}",
reason
)]
ParseEpisodeError { reason: String, parent_id: i32 },
#[fail(display = "Episode was not changed and thus skipped.")]
EpisodeNotChanged,
}
// Maps a type to a variant of the DataError enum
macro_rules! easy_from_impl {
($outer_type:ty, $from:ty => $to:expr) => (
impl From<$from> for $outer_type {
fn from(err: $from) -> Self {
$to(err)
}
}
);
($outer_type:ty, $from:ty => $to:expr, $($f:ty => $t:expr),+) => (
easy_from_impl!($outer_type, $from => $to);
easy_from_impl!($outer_type, $($f => $t),+);
);
}
easy_from_impl!(
DataError,
RunMigrationsError => DataError::DieselMigrationError,
diesel::result::Error => DataError::DieselResultError,
r2d2::Error => DataError::R2D2Error,
r2d2::PoolError => DataError::R2D2PoolError,
hyper::Error => DataError::HyperError,
http::header::ToStrError => DataError::HttpToStr,
url::ParseError => DataError::UrlError,
native_tls::Error => DataError::TLSError,
io::Error => DataError::IOError,
rss::Error => DataError::RssError,
xml::reader::Error => DataError::XmlReaderError,
String => DataError::Bail
);

240
podcasts-data/src/feed.rs Normal file
View File

@ -0,0 +1,240 @@
// feed.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(clippy::unit_arg)]
//! Index Feeds.
use futures::future::*;
use futures::prelude::*;
use futures::stream;
use rss;
use crate::dbqueries;
use crate::errors::DataError;
use crate::models::{Index, IndexState, Update};
use crate::models::{NewEpisode, NewEpisodeMinimal, NewShow, Show};
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field.
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub struct Feed {
/// The `rss::Channel` parsed from the `Source` uri.
channel: rss::Channel,
/// The `Source` id where the xml `rss::Channel` came from.
source_id: i32,
}
impl Feed {
/// Index the contents of the RSS `Feed` into the database.
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send {
ok(self.parse_podcast())
.and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(pd))
}
fn parse_podcast(&self) -> NewShow {
NewShow::new(&self.channel, self.source_id)
}
fn index_channel_items(self, pd: Show) -> impl Future<Item = (), Error = DataError> + Send {
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
// Parse the episodes
let episodes = stream.filter_map(move |item| {
NewEpisodeMinimal::new(&item, pd.id())
.and_then(move |ep| determine_ep_state(ep, &item))
.map_err(|err| error!("Failed to parse an episode: {}", err))
.ok()
});
// Filter errors, Index updatable episodes, return insertables.
filter_episodes(episodes)
// Batch index insertable episodes.
.and_then(|eps| ok(batch_insert_episodes(&eps)))
}
}
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.show_id())?;
if !exists {
Ok(IndexState::Index(ep.into_new_episode(item)))
} else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.show_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();
});
})
}
#[cfg(test)]
mod tests {
use failure::Error;
use rss::Channel;
use tokio::{self, prelude::*};
use crate::database::truncate_db;
use crate::dbqueries;
use crate::utils::get_feed;
use crate::Source;
use std::fs;
use std::io::BufReader;
use super::*;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test]
fn test_complete_index() -> Result<(), Error> {
truncate_db()?;
let feeds: Vec<_> = URLS
.iter()
.map(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
get_feed(path, s.id())
})
.collect();
// Index the channels
let stream_ = stream::iter_ok(feeds).for_each(|x| x.index());
tokio::run(stream_.map_err(|_| ()));
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources()?.len(), 5);
assert_eq!(dbqueries::get_podcasts()?.len(), 5);
assert_eq!(dbqueries::get_episodes()?.len(), 354);
Ok(())
}
#[test]
fn test_feed_parse_podcast() -> Result<(), Error> {
truncate_db()?;
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let file = fs::File::open(path)?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewShow::new(&channel, 42);
assert_eq!(feed.parse_podcast(), pd);
Ok(())
}
#[test]
fn test_feed_index_channel_items() -> Result<(), Error> {
truncate_db()?;
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast()?;
feed.index_channel_items(pd).wait()?;
assert_eq!(dbqueries::get_podcasts()?.len(), 1);
assert_eq!(dbqueries::get_episodes()?.len(), 43);
Ok(())
}
}

145
podcasts-data/src/lib.rs Normal file
View File

@ -0,0 +1,145 @@
// lib.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#![recursion_limit = "1024"]
#![allow(unknown_lints)]
#![cfg_attr(
all(test, feature = "clippy"),
allow(option_unwrap_used, result_unwrap_used)
)]
#![cfg_attr(
feature = "clippy",
warn(
option_unwrap_used,
result_unwrap_used,
print_stdout,
wrong_pub_self_convention,
mut_mut,
non_ascii_literal,
similar_names,
unicode_not_nfc,
enum_glob_use,
if_not_else,
items_after_statements,
used_underscore_binding
)
)]
// Enable lint group collections
#![warn(nonstandard_style, bad_style, unused)]
#![warn(edition_2018, rust_2018_idioms)]
// standalone lints
#![warn(
const_err,
improper_ctypes,
non_shorthand_field_patterns,
no_mangle_generic_items,
overflowing_literals,
plugin_as_library,
unconditional_recursion,
unions_with_drop_fields,
while_true,
missing_debug_implementations,
missing_docs,
trivial_casts,
trivial_numeric_casts,
elided_lifetime_in_paths,
missing_copy_implementations
)]
#![allow(proc_macro_derive_resolution_fallback)]
//! FIXME: Docs
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
#[cfg(test)]
#[macro_use]
extern crate maplit;
#[macro_use]
extern crate derive_builder;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
// #[macro_use]
// extern crate failure;
#[macro_use]
extern crate failure_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
pub mod database;
#[allow(missing_docs)]
pub mod dbqueries;
#[allow(missing_docs)]
pub mod errors;
mod feed;
pub(crate) mod models;
pub mod opml;
mod parser;
pub mod pipeline;
mod schema;
pub mod utils;
pub use crate::feed::{Feed, FeedBuilder};
pub use crate::models::Save;
pub use crate::models::{Episode, EpisodeWidgetModel, Show, ShowCoverModel, Source};
// Set the user agent, See #53 for more
// Keep this in sync with Tor-browser releases
/// The user-agent to be used for all the requests.
/// It originates from the Tor-browser UA.
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0";
/// [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]
pub mod xdg_dirs {
use std::path::PathBuf;
use xdg;
lazy_static! {
pub(crate) static ref PODCASTS_XDG: xdg::BaseDirectories = {
xdg::BaseDirectories::with_prefix("gnome-podcasts").unwrap()
};
/// XDG_DATA Directory `Pathbuf`.
pub static ref PODCASTS_DATA: PathBuf = {
PODCASTS_XDG.create_data_directory(PODCASTS_XDG.get_data_home()).unwrap()
};
/// XDG_CONFIG Directory `Pathbuf`.
pub static ref PODCASTS_CONFIG: PathBuf = {
PODCASTS_XDG.create_config_directory(PODCASTS_XDG.get_config_home()).unwrap()
};
/// XDG_CACHE Directory `Pathbuf`.
pub static ref PODCASTS_CACHE: PathBuf = {
PODCASTS_XDG.create_cache_directory(PODCASTS_XDG.get_cache_home()).unwrap()
};
/// GNOME Podcasts Download Directory `PathBuf`.
pub static ref DL_DIR: PathBuf = {
PODCASTS_XDG.create_data_directory("Downloads").unwrap()
};
}
}

View File

@ -0,0 +1,19 @@
data_sources = files(
'models/episode.rs',
'models/mod.rs',
'models/new_episode.rs',
'models/new_show.rs',
'models/new_source.rs',
'models/show.rs',
'models/source.rs',
'database.rs',
'dbqueries.rs',
'errors.rs',
'feed.rs',
'lib.rs',
'opml.rs',
'parser.rs',
'pipeline.rs',
'schema.rs',
'utils.rs',
)

View File

@ -1,18 +1,37 @@
// episode.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use chrono::prelude::*;
use diesel;
use diesel::SaveChangesDsl;
use diesel::prelude::*;
use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
use models::{Podcast, Save};
use schema::episode;
use crate::database::connection;
use crate::errors::DataError;
use crate::models::{Save, Show};
use crate::schema::episodes;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[belongs_to(Podcast, foreign_key = "podcast_id")]
#[primary_key(title, show_id)]
#[belongs_to(Show, foreign_key = "show_id")]
#[derive(Debug, Clone)]
/// Diesel Model of the episode table.
pub struct Episode {
@ -26,15 +45,15 @@ pub struct Episode {
duration: Option<i32>,
guid: Option<String>,
played: Option<i32>,
favorite: bool,
archive: bool,
podcast_id: i32,
show_id: i32,
}
impl Save<Episode, DataError> for Episode {
impl Save<Episode> for Episode {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Episode, DataError> {
fn save(&self) -> Result<Episode, Self::Error> {
let db = connection();
let tempdb = db.get()?;
@ -53,11 +72,6 @@ impl Episode {
&self.title
}
/// Set the `title`.
pub fn set_title(&mut self, value: &str) {
self.title = value.to_string();
}
/// Get the value of the `uri`.
///
/// Represents the url(usually) that the media file will be located at.
@ -65,11 +79,6 @@ impl Episode {
self.uri.as_ref().map(|s| s.as_str())
}
/// Set the `uri`.
pub fn set_uri(&mut self, value: Option<&str>) {
self.uri = value.map(|x| x.to_string());
}
/// Get the value of the `local_uri`.
///
/// Represents the local uri,usually filesystem path,
@ -78,31 +87,16 @@ impl Episode {
self.local_uri.as_ref().map(|s| s.as_str())
}
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Get the `description`.
pub fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str())
}
/// Set the `description`.
pub fn set_description(&mut self, value: Option<&str>) {
self.description = value.map(|x| x.to_string());
}
/// Get the Episode's `guid`.
pub fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
/// Set the `guid`.
pub fn set_guid(&mut self, value: Option<&str>) {
self.guid = value.map(|x| x.to_string());
}
/// Get the `epoch` value.
///
/// Retrieved from the rss Item publish date.
@ -111,11 +105,6 @@ impl Episode {
self.epoch
}
/// Set the `epoch`.
pub fn set_epoch(&mut self, value: i32) {
self.epoch = value;
}
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
@ -123,11 +112,6 @@ impl Episode {
self.length
}
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
@ -135,11 +119,6 @@ impl Episode {
self.duration
}
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
@ -147,54 +126,19 @@ impl Episode {
self.played
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
///
/// If true, the download cleanr will ignore the episode
/// and the corresponding media value will never be automaticly deleted.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Episode`.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
pub fn set_played_now(&mut self) -> Result<(), DataError> {
let epoch = Utc::now().timestamp() as i32;
self.set_played(Some(epoch));
self.save().map(|_| ())
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for constructing `EpisodeWidgets`.
pub struct EpisodeWidgetQuery {
pub struct EpisodeWidgetModel {
rowid: i32,
title: String,
uri: Option<String>,
@ -203,14 +147,12 @@ pub struct EpisodeWidgetQuery {
length: Option<i32>,
duration: Option<i32>,
played: Option<i32>,
// favorite: bool,
// archive: bool,
podcast_id: i32,
show_id: i32,
}
impl From<Episode> for EpisodeWidgetQuery {
fn from(e: Episode) -> EpisodeWidgetQuery {
EpisodeWidgetQuery {
impl From<Episode> for EpisodeWidgetModel {
fn from(e: Episode) -> EpisodeWidgetModel {
EpisodeWidgetModel {
rowid: e.rowid,
title: e.title,
uri: e.uri,
@ -219,28 +161,30 @@ impl From<Episode> for EpisodeWidgetQuery {
length: e.length,
duration: e.duration,
played: e.played,
podcast_id: e.podcast_id,
show_id: e.show_id,
}
}
}
impl Save<usize, DataError> for EpisodeWidgetQuery {
impl Save<usize> for EpisodeWidgetModel {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
fn save(&self) -> Result<usize, Self::Error> {
use crate::schema::episodes::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
diesel::update(episodes.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl EpisodeWidgetQuery {
impl EpisodeWidgetModel {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
@ -298,11 +242,6 @@ impl EpisodeWidgetQuery {
self.duration
}
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
@ -311,36 +250,13 @@ impl EpisodeWidgetQuery {
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
// /// Represents the archiving policy for the episode.
// pub fn archive(&self) -> bool {
// self.archive
// }
// /// Set the `archive` policy.
// ///
// /// If true, the download cleanr will ignore the episode
// /// and the corresponding media value will never be automaticly deleted.
// pub fn set_archive(&mut self, b: bool) {
// self.archive = b
// }
// /// Get the `favorite` status of the `Episode`.
// pub fn favorite(&self) -> bool {
// self.favorite
// }
// /// Set `favorite` status.
// pub fn set_favorite(&mut self, b: bool) {
// self.favorite = b
// }
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
@ -352,36 +268,38 @@ impl EpisodeWidgetQuery {
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used internal with the `utils::checkup` function.
pub struct EpisodeCleanerQuery {
pub struct EpisodeCleanerModel {
rowid: i32,
local_uri: Option<String>,
played: Option<i32>,
}
impl Save<usize, DataError> for EpisodeCleanerQuery {
impl Save<usize> for EpisodeCleanerModel {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
fn save(&self) -> Result<usize, Self::Error> {
use crate::schema::episodes::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
diesel::update(episodes.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl From<Episode> for EpisodeCleanerQuery {
fn from(e: Episode) -> EpisodeCleanerQuery {
EpisodeCleanerQuery {
impl From<Episode> for EpisodeCleanerModel {
fn from(e: Episode) -> EpisodeCleanerModel {
EpisodeCleanerModel {
rowid: e.rowid(),
local_uri: e.local_uri,
played: e.played,
@ -389,7 +307,7 @@ impl From<Episode> for EpisodeCleanerQuery {
}
}
impl EpisodeCleanerQuery {
impl EpisodeCleanerModel {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
@ -422,9 +340,9 @@ impl EpisodeCleanerQuery {
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for FIXME.
pub struct EpisodeMinimal {
@ -432,9 +350,10 @@ pub struct EpisodeMinimal {
title: String,
uri: Option<String>,
epoch: i32,
length: Option<i32>,
duration: Option<i32>,
guid: Option<String>,
podcast_id: i32,
show_id: i32,
}
impl From<Episode> for EpisodeMinimal {
@ -443,10 +362,11 @@ impl From<Episode> for EpisodeMinimal {
rowid: e.rowid,
title: e.title,
uri: e.uri,
length: e.length,
guid: e.guid,
epoch: e.epoch,
duration: e.duration,
podcast_id: e.podcast_id,
show_id: e.show_id,
}
}
}
@ -482,6 +402,13 @@ impl EpisodeMinimal {
self.epoch
}
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
@ -489,8 +416,8 @@ impl EpisodeMinimal {
self.duration
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
}

View File

@ -0,0 +1,78 @@
// mod.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
mod new_episode;
mod new_show;
mod new_source;
mod episode;
mod show;
mod source;
// use futures::prelude::*;
// use futures::future::*;
pub(crate) use self::episode::EpisodeCleanerModel;
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
pub(crate) use self::new_show::NewShow;
pub(crate) use self::new_source::NewSource;
#[cfg(test)]
pub(crate) use self::new_episode::NewEpisodeBuilder;
#[cfg(test)]
pub(crate) use self::new_show::NewShowBuilder;
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetModel};
pub use self::show::{Show, ShowCoverModel};
pub use self::source::Source;
#[derive(Debug, Clone, PartialEq)]
pub enum IndexState<T> {
Index(T),
Update((T, i32)),
NotChanged,
}
pub trait Insert<T> {
type Error;
fn insert(&self) -> Result<T, Self::Error>;
}
pub trait Update<T> {
type Error;
fn update(&self, _: i32) -> Result<T, Self::Error>;
}
// This might need to change in the future
pub trait Index<T>: Insert<T> + Update<T> {
type Error;
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
}
/// FIXME: DOCS
pub trait Save<T> {
/// The Error type to be returned.
type Error;
/// Helper method to easily save/"sync" current state of a diesel model to
/// the Database.
fn save(&self) -> Result<T, Self::Error>;
}

View File

@ -1,19 +1,38 @@
// new_episode.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use ammonia;
use diesel;
use diesel::prelude::*;
use rfc822_sanitizer::parse_from_rfc2822_with_fallback as parse_rfc822;
use rss;
use database::connection;
use dbqueries;
use errors::DataError;
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
use parser;
use schema::episode;
use utils::url_cleaner;
use crate::database::connection;
use crate::dbqueries;
use crate::errors::DataError;
use crate::models::{Episode, EpisodeMinimal, Index, Insert, Update};
use crate::parser;
use crate::schema::episodes;
use crate::utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
@ -26,7 +45,7 @@ pub(crate) struct NewEpisode {
duration: Option<i32>,
guid: Option<String>,
epoch: i32,
podcast_id: i32,
show_id: i32,
}
impl From<NewEpisodeMinimal> for NewEpisode {
@ -36,21 +55,23 @@ impl From<NewEpisodeMinimal> for NewEpisode {
.uri(e.uri)
.duration(e.duration)
.epoch(e.epoch)
.podcast_id(e.podcast_id)
.show_id(e.show_id)
.guid(e.guid)
.build()
.unwrap()
}
}
impl Insert<(), DataError> for NewEpisode {
impl Insert<()> for NewEpisode {
type Error = DataError;
fn insert(&self) -> Result<(), DataError> {
use schema::episode::dsl::*;
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
info!("Inserting {:?}", self.title);
diesel::insert_into(episode)
diesel::insert_into(episodes)
.values(self)
.execute(&con)
.map_err(From::from)
@ -58,14 +79,16 @@ 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> {
use schema::episode::dsl::*;
use crate::schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {:?}", self.title);
diesel::update(episode.filter(rowid.eq(episode_id)))
diesel::update(episodes.filter(rowid.eq(episode_id)))
.set(self)
.execute(&con)
.map_err(From::from)
@ -73,14 +96,16 @@ impl Update<(), DataError> for NewEpisode {
}
}
impl Index<(), DataError> for NewEpisode {
impl Index<()> for NewEpisode {
type Error = DataError;
// Does not update the episode description if it's the only thing that has
// changed.
fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
let exists = dbqueries::episode_exists(self.title(), self.show_id())?;
if exists {
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.podcast_id())?;
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.show_id())?;
if self != &other {
self.update(other.rowid())
@ -95,17 +120,23 @@ impl Index<(), DataError> for NewEpisode {
impl PartialEq<EpisodeMinimal> for NewEpisode {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
}
}
impl PartialEq<Episode> for NewEpisode {
fn eq(&self, other: &Episode) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
&& (self.description() == other.description())
&& (self.length() == other.length())
}
@ -114,14 +145,14 @@ impl PartialEq<Episode> for NewEpisode {
impl NewEpisode {
/// Parses an `rss::Item` into a `NewEpisode` Struct.
#[allow(dead_code)]
pub(crate) fn new(item: &rss::Item, podcast_id: i32) -> Result<Self, DataError> {
NewEpisodeMinimal::new(item, podcast_id).map(|ep| ep.into_new_episode(item))
pub(crate) fn new(item: &rss::Item, show_id: i32) -> Result<Self, DataError> {
NewEpisodeMinimal::new(item, show_id).map(|ep| ep.into_new_episode(item))
}
#[allow(dead_code)]
pub(crate) fn to_episode(&self) -> Result<Episode, DataError> {
self.index()?;
dbqueries::get_episode_from_pk(&self.title, self.podcast_id).map_err(From::from)
dbqueries::get_episode_from_pk(&self.title, self.show_id).map_err(From::from)
}
}
@ -155,30 +186,34 @@ impl NewEpisode {
self.length
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
pub(crate) fn show_id(&self) -> i32 {
self.show_id
}
}
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewEpisodeMinimal {
title: String,
uri: Option<String>,
length: Option<i32>,
duration: Option<i32>,
epoch: i32,
guid: Option<String>,
podcast_id: i32,
show_id: i32,
}
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
}
}
@ -196,12 +231,20 @@ impl NewEpisodeMinimal {
let title = item.title().unwrap().trim().to_owned();
let guid = item.guid().map(|s| s.value().trim().to_owned());
let uri = item.enclosure()
.map(|s| url_cleaner(s.url()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s)));
// Get the mime type, the `http` url and the length from the enclosure
// http://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt
let enc = item.enclosure();
// If url is still None return an Error as this behaviour is
// Get the url
let uri = enc
.map(|s| url_cleaner(s.url().trim()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s.trim())));
// Get the size of the content, it should be in bytes
let length = enc.and_then(|x| x.length().parse().ok());
// If url is still None return an Error as this behaviour is not
// compliant with the RSS Spec.
if uri.is_none() {
let err = DataError::ParseEpisodeError {
@ -212,7 +255,7 @@ impl NewEpisodeMinimal {
return Err(err);
};
// Default to rfc2822 represantation of epoch 0.
// Default to rfc2822 representation of epoch 0.
let date = parse_rfc822(item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"));
// Should treat information from the rss feeds as invalid by default.
// Case: "Thu, 05 Aug 2016 06:00:00 -0400" <-- Actually that was friday.
@ -223,27 +266,35 @@ impl NewEpisodeMinimal {
NewEpisodeMinimalBuilder::default()
.title(title)
.uri(uri)
.length(length)
.duration(duration)
.epoch(epoch)
.guid(guid)
.podcast_id(parent_id)
.show_id(parent_id)
.build()
.map_err(From::from)
}
// TODO: TryInto is stabilizing in rustc v1.26!
// ^ Jokes on you past self!
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
let length = item.enclosure().and_then(|x| x.length().parse().ok());
let description = item.description().map(|s| ammonia::clean(s));
let description = item.description().and_then(|s| {
let sanitized_html = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(s.trim())
.to_string();
Some(sanitized_html)
});
NewEpisodeBuilder::default()
.title(self.title)
.uri(self.uri)
.duration(self.duration)
.epoch(self.epoch)
.podcast_id(self.podcast_id)
.show_id(self.show_id)
.guid(self.guid)
.length(length)
.length(self.length)
.description(description)
.build()
.unwrap()
@ -272,16 +323,18 @@ impl NewEpisodeMinimal {
self.epoch
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
pub(crate) fn show_id(&self) -> i32 {
self.show_id
}
}
#[cfg(test)]
mod tests {
use database::truncate_db;
use dbqueries;
use models::*;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use crate::database::truncate_db;
use crate::dbqueries;
use crate::models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use crate::models::*;
use failure::Error;
use rss::Channel;
@ -289,7 +342,7 @@ mod tests {
use std::io::BufReader;
// TODO: Add tests for other feeds too.
// Especially if you find an *intresting* generated feed.
// Especially if you find an *interesting* generated feed.
// Known prebuilt expected objects.
lazy_static! {
@ -301,8 +354,9 @@ mod tests {
)))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.epoch(1505296800)
.length(Some(66738886))
.duration(Some(4171))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -314,8 +368,9 @@ mod tests {
)))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.epoch(1502272800)
.length(Some(67527575))
.duration(Some(4415))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -336,7 +391,7 @@ mod tests {
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(4171))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -360,7 +415,7 @@ mod tests {
.length(Some(67527575))
.epoch(1502272800)
.duration(Some(4415))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -375,7 +430,7 @@ mod tests {
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(424242))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -386,9 +441,10 @@ mod tests {
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
)))
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.length(Some(46479789))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -400,8 +456,9 @@ mod tests {
)))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.epoch(1504670247)
.length(Some(36544272))
.duration(Some(4491))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -421,7 +478,7 @@ mod tests {
.length(Some(46479789))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@ -443,80 +500,88 @@ mod tests {
.length(Some(36544272))
.epoch(1504670247)
.duration(Some(4491))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
}
#[test]
fn test_new_episode_minimal_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_episode_minimal_intercepted() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_2);
Ok(())
}
#[test]
fn test_new_episode_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_episode_intercepted() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
let ep = NewEpisode::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
let ep = NewEpisode::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
Ok(())
}
#[test]
fn test_new_episode_minimal_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_episode_minimal_lup() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_1);
let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_2);
Ok(())
}
#[test]
fn test_new_episode_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_episode_lup() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
let ep = NewEpisode::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_LUP_1);
let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
let ep = NewEpisode::new(&episode, 42)?;
assert_eq!(ep, *EXPECTED_LUP_2);
Ok(())
}
#[test]
fn test_minimal_into_new_episode() {
truncate_db().unwrap();
fn test_minimal_into_new_episode() -> Result<(), Error> {
truncate_db()?;
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let item = channel.items().iter().nth(14).unwrap();
let ep = EXPECTED_MINIMAL_INTERCEPTED_1
.clone()
.into_new_episode(&item);
println!(
"EPISODE: {:#?}\nEXPECTED: {:#?}",
ep, *EXPECTED_INTERCEPTED_1
);
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let item = channel.items().iter().nth(15).unwrap();
@ -524,61 +589,58 @@ mod tests {
.clone()
.into_new_episode(&item);
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
Ok(())
}
#[test]
fn test_new_episode_insert() {
truncate_db().unwrap();
fn test_new_episode_insert() -> Result<(), Error> {
truncate_db()?;
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let episode = channel.items().iter().nth(14).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
let new_ep = NewEpisode::new(&episode, 42)?;
new_ep.insert()?;
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?;
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1);
assert_eq!(&*EXPECTED_INTERCEPTED_1, &ep);
let episode = channel.items().iter().nth(15).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
let new_ep = NewEpisode::new(&episode, 42)?;
new_ep.insert()?;
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?;
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2);
assert_eq!(&*EXPECTED_INTERCEPTED_2, &ep);
Ok(())
}
#[test]
fn test_new_episode_update() {
truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED_1.clone().to_episode().unwrap();
fn test_new_episode_update() -> Result<(), Error> {
truncate_db()?;
let old = EXPECTED_INTERCEPTED_1.clone().to_episode()?;
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
updated.update(old.rowid()).unwrap();
let mut new = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
updated.update(old.rowid())?;
let new = dbqueries::get_episode_from_pk(old.title(), old.show_id())?;
// Assert that updating does not change the rowid and podcast_id
// Assert that updating does not change the rowid and show_id
assert_ne!(old, new);
assert_eq!(old.rowid(), new.rowid());
assert_eq!(old.podcast_id(), new.podcast_id());
assert_eq!(old.show_id(), new.show_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
assert_eq!(true, new2.archive());
Ok(())
}
#[test]
fn test_new_episode_index() {
truncate_db().unwrap();
fn test_new_episode_index() -> Result<(), Error> {
truncate_db()?;
let expected = &*EXPECTED_INTERCEPTED_1;
// First insert
@ -586,7 +648,7 @@ mod tests {
// Second identical, This should take the early return path
assert!(expected.index().is_ok());
// Get the episode
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
// Assert that NewPodcast is equal to the Indexed one
assert_eq!(*expected, old);
@ -595,42 +657,33 @@ mod tests {
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
let new = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let new = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
// Assert it's diff from the old one.
assert_ne!(new, old);
assert_eq!(*updated, new);
assert_eq!(new.rowid(), old.rowid());
assert_eq!(new.podcast_id(), old.podcast_id());
assert_eq!(new.show_id(), old.show_id());
Ok(())
}
#[test]
fn test_new_episode_to_episode() {
fn test_new_episode_to_episode() -> Result<(), Error> {
let expected = &*EXPECTED_INTERCEPTED_1;
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
// Assert insert() produces the same result that you would get with to_podcast()
truncate_db().unwrap();
expected.insert().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let ep = expected.to_episode().unwrap();
truncate_db()?;
expected.insert()?;
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
let ep = expected.to_episode()?;
assert_eq!(old, ep);
// Same as above, diff order
truncate_db().unwrap();
let ep = expected.to_episode().unwrap();
truncate_db()?;
let ep = expected.to_episode()?;
// This should error as a unique constrain violation
assert!(expected.insert().is_err());
let mut old =
dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?;
assert_eq!(old, ep);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let ep = updated.to_episode().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
assert_eq!(old, ep);
assert_eq!(old.archive(), true);
Ok(())
}
}

View File

@ -1,24 +1,43 @@
// new_show.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use ammonia;
use diesel;
use diesel::prelude::*;
use rss;
use errors::DataError;
use models::{Index, Insert, Update};
use models::Podcast;
use schema::podcast;
use crate::errors::DataError;
use crate::models::Show;
use crate::models::{Index, Insert, Update};
use crate::schema::shows;
use database::connection;
use dbqueries;
use utils::url_cleaner;
use crate::database::connection;
use crate::dbqueries;
use crate::utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "podcast"]
#[table_name = "shows"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewPodcast {
pub(crate) struct NewShow {
title: String,
link: String,
description: String,
@ -26,13 +45,15 @@ pub(crate) struct NewPodcast {
source_id: i32,
}
impl Insert<(), DataError> for NewPodcast {
fn insert(&self) -> Result<(), DataError> {
use schema::podcast::dsl::*;
impl Insert<()> for NewShow {
type Error = DataError;
fn insert(&self) -> Result<(), Self::Error> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(podcast)
diesel::insert_into(shows)
.values(self)
.execute(&con)
.map(|_| ())
@ -40,14 +61,16 @@ impl Insert<(), DataError> for NewPodcast {
}
}
impl Update<(), DataError> for NewPodcast {
fn update(&self, podcast_id: i32) -> Result<(), DataError> {
use schema::podcast::dsl::*;
impl Update<()> for NewShow {
type Error = DataError;
fn update(&self, show_id: i32) -> Result<(), Self::Error> {
use crate::schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {}", self.title);
diesel::update(podcast.filter(id.eq(podcast_id)))
diesel::update(shows.filter(id.eq(show_id)))
.set(self)
.execute(&con)
.map(|_| ())
@ -57,7 +80,9 @@ impl Update<(), DataError> for NewPodcast {
// TODO: Maybe return an Enum<Action(Resut)> Instead.
// It would make unti testing better too.
impl Index<(), DataError> for NewPodcast {
impl Index<()> for NewShow {
type Error = DataError;
fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::podcast_exists(self.source_id)?;
@ -75,31 +100,37 @@ impl Index<(), DataError> for NewPodcast {
}
}
impl PartialEq<Podcast> for NewPodcast {
fn eq(&self, other: &Podcast) -> bool {
(self.link() == other.link()) && (self.title() == other.title())
impl PartialEq<Show> for NewShow {
fn eq(&self, other: &Show) -> bool {
(self.link() == other.link())
&& (self.title() == other.title())
&& (self.image_uri() == other.image_uri())
&& (self.description() == other.description())
&& (self.source_id() == other.source_id())
}
}
impl NewPodcast {
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
impl NewShow {
/// Parses a `rss::Channel` into a `NewShow` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewShow {
let title = chan.title().trim();
let link = url_cleaner(chan.link().trim());
let description = ammonia::clean(chan.description().trim());
let link = url_cleaner(chan.link());
let description = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(chan.description().trim())
.to_string();
// Try to get the itunes img first
let itunes_img = chan.itunes_ext()
.and_then(|s| s.image())
let itunes_img = chan
.itunes_ext()
.and_then(|s| s.image().map(|url| url.trim()))
.map(|s| s.to_owned());
// If itunes is None, try to get the channel.image from the rss spec
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().to_owned()));
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned()));
NewPodcastBuilder::default()
NewShowBuilder::default()
.title(title)
.description(description)
.link(link)
@ -110,14 +141,14 @@ impl NewPodcast {
}
// Look out for when tryinto lands into stable.
pub(crate) fn to_podcast(&self) -> Result<Podcast, DataError> {
pub(crate) fn to_podcast(&self) -> Result<Show, DataError> {
self.index()?;
dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from)
}
}
// Ignore the following geters. They are used in unit tests mainly.
impl NewPodcast {
impl NewShow {
#[allow(dead_code)]
pub(crate) fn source_id(&self) -> i32 {
self.source_id
@ -145,17 +176,18 @@ mod tests {
use super::*;
// use tokio_core::reactor::Core;
use failure::Error;
use rss::Channel;
use database::truncate_db;
use models::{NewPodcastBuilder, Save};
use crate::database::truncate_db;
use crate::models::NewShowBuilder;
use std::fs::File;
use std::io::BufReader;
// Pre-built expected NewPodcast structs.
// Pre-built expected NewShow structs.
lazy_static! {
static ref EXPECTED_INTERCEPTED: NewPodcast = {
static ref EXPECTED_INTERCEPTED: NewShow = {
let descr = "The people behind The Intercepts fearless reporting and incisive \
commentaryJeremy Scahill, Glenn Greenwald, Betsy Reed and \
othersdiscuss the crucial issues of our time: national security, civil \
@ -163,7 +195,7 @@ mod tests {
artists, thinkers, and newsmakers who challenge our preconceptions about \
the world we live in.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description(descr)
@ -176,12 +208,12 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_LUP: NewPodcast = {
static ref EXPECTED_LUP: NewShow = {
let descr = "An open show powered by community LINUX Unplugged takes the best \
attributes of open collaboration and focuses them into a weekly \
lifestyle show about Linux.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("LINUX Unplugged Podcast")
.link("http://www.jupiterbroadcasting.com/")
.description(descr)
@ -192,7 +224,7 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_TIPOFF: NewPodcast = {
static ref EXPECTED_TIPOFF: NewShow = {
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
scenes of some of the best investigative journalism from recent years. \
Each episode well be digging into an investigative scoop- hearing from \
@ -203,7 +235,7 @@ mod tests {
complicated detective work that goes into doing great investigative \
journalism- then this is the podcast for you.</p>";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("The Tip Off")
.link("http://www.acast.com/thetipoff")
.description(desc)
@ -215,7 +247,7 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_STARS: NewPodcast = {
static ref EXPECTED_STARS: NewShow = {
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars \
is a gripping noir science fiction thriller in 14 episodes: Forbidden \
love, a crashed UFO, an alien body, and an impossible heist unlike any \
@ -225,7 +257,7 @@ mod tests {
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Steal the Stars")
.link("http://tor-labs.com/")
.description(descr)
@ -234,12 +266,12 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_CODE: NewPodcast = {
static ref EXPECTED_CODE: NewShow = {
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \
David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \
Livingston-Gray. Brought to you by @therubyrep.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Greater Than Code")
.link("https://www.greaterthancode.com/")
.description(descr)
@ -250,8 +282,8 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
NewPodcastBuilder::default()
static ref EXPECTED_ELLINOFRENEIA: NewShow = {
NewShowBuilder::default()
.title("Ελληνοφρένεια")
.link("https://ellinofreneia.sealabs.net/feed.rss")
.description("Ανεπίσημο feed της Ελληνοφρένειας")
@ -260,8 +292,8 @@ mod tests {
.build()
.unwrap()
};
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
NewPodcastBuilder::default()
static ref UPDATED_DESC_INTERCEPTED: NewShow = {
NewShowBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description("New Description")
@ -277,73 +309,80 @@ mod tests {
}
#[test]
fn test_new_podcast_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_intercepted() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_INTERCEPTED, pd);
Ok(())
}
#[test]
fn test_new_podcast_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_lup() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_LUP, pd);
Ok(())
}
#[test]
fn test_new_podcast_thetipoff() {
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_thetipoff() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_TIPOFF, pd);
Ok(())
}
#[test]
fn test_new_podcast_steal_the_stars() {
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_steal_the_stars() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_STARS, pd);
Ok(())
}
#[test]
fn test_new_podcast_greater_than_code() {
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_greater_than_code() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_CODE, pd);
Ok(())
}
#[test]
fn test_new_podcast_ellinofreneia() {
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_ellinofreneia() -> Result<(), Error> {
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
Ok(())
}
#[test]
// This maybe could be a doc test on insert.
fn test_new_podcast_insert() {
truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_new_podcast_insert() -> Result<(), Error> {
truncate_db()?;
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?;
let channel = Channel::read_from(BufReader::new(file))?;
let npd = NewPodcast::new(&channel, 42);
npd.insert().unwrap();
let pd = dbqueries::get_podcast_from_source_id(42).unwrap();
let npd = NewShow::new(&channel, 42);
npd.insert()?;
let pd = dbqueries::get_podcast_from_source_id(42)?;
assert_eq!(npd, pd);
assert_eq!(*EXPECTED_INTERCEPTED, npd);
assert_eq!(&*EXPECTED_INTERCEPTED, &pd);
Ok(())
}
#[test]
@ -351,77 +390,64 @@ mod tests {
// Currently there's a test that only checks new description or title.
// If you have time and want to help, implement the test for the other fields
// too.
fn test_new_podcast_update() {
truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
fn test_new_podcast_update() -> Result<(), Error> {
truncate_db()?;
let old = EXPECTED_INTERCEPTED.to_podcast()?;
let updated = &*UPDATED_DESC_INTERCEPTED;
updated.update(old.id()).unwrap();
let mut new = dbqueries::get_podcast_from_source_id(42).unwrap();
updated.update(old.id())?;
let new = dbqueries::get_podcast_from_source_id(42)?;
assert_ne!(old, new);
assert_eq!(old.id(), new.id());
assert_eq!(old.source_id(), new.source_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
// Chech that the update does not override user preferences.
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(true, new2.archive());
Ok(())
}
#[test]
fn test_new_podcast_index() {
truncate_db().unwrap();
fn test_new_podcast_index() -> Result<(), Error> {
truncate_db()?;
// First insert
assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Second identical, This should take the early return path
assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Get the podcast
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert that NewPodcast is equal to the Indexed one
let old = dbqueries::get_podcast_from_source_id(42)?;
// Assert that NewShow is equal to the Indexed one
assert_eq!(&*EXPECTED_INTERCEPTED, &old);
let updated = &*UPDATED_DESC_INTERCEPTED;
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
let new = dbqueries::get_podcast_from_source_id(42).unwrap();
// Get the new Show
let new = dbqueries::get_podcast_from_source_id(42)?;
// Assert it's diff from the old one.
assert_ne!(new, old);
assert_eq!(new.id(), old.id());
assert_eq!(new.source_id(), old.source_id());
Ok(())
}
#[test]
fn test_to_podcast() {
fn test_to_podcast() -> Result<(), Error> {
// Assert insert() produces the same result that you would get with to_podcast()
truncate_db().unwrap();
EXPECTED_INTERCEPTED.insert().unwrap();
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
truncate_db()?;
EXPECTED_INTERCEPTED.insert()?;
let old = dbqueries::get_podcast_from_source_id(42)?;
let pd = EXPECTED_INTERCEPTED.to_podcast()?;
assert_eq!(old, pd);
// Same as above, diff order
truncate_db().unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
truncate_db()?;
let pd = EXPECTED_INTERCEPTED.to_podcast()?;
// This should error as a unique constrain violation
assert!(EXPECTED_INTERCEPTED.insert().is_err());
let mut old = dbqueries::get_podcast_from_source_id(42).unwrap();
let old = dbqueries::get_podcast_from_source_id(42)?;
assert_eq!(old, pd);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let pd = UPDATED_DESC_INTERCEPTED.to_podcast().unwrap();
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd);
assert_eq!(old.archive(), true);
Ok(())
}
}

View File

@ -1,15 +1,32 @@
#![allow(unused_mut)]
// new_source.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use diesel;
use diesel::prelude::*;
use url::Url;
use database::connection;
use dbqueries;
use crate::database::connection;
use crate::dbqueries;
// use models::{Insert, Update};
use errors::DataError;
use models::Source;
use schema::source;
use crate::errors::DataError;
use crate::models::Source;
use crate::schema::source;
#[derive(Insertable)]
#[table_name = "source"]
@ -33,7 +50,7 @@ impl NewSource {
}
pub(crate) fn insert_or_ignore(&self) -> Result<(), DataError> {
use schema::source::dsl::*;
use crate::schema::source::dsl::*;
let db = connection();
let con = db.get()?;

View File

@ -0,0 +1,110 @@
// show.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use crate::models::Source;
use crate::schema::shows;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "shows"]
#[derive(Debug, Clone)]
/// Diesel Model of the shows table.
pub struct Show {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
source_id: i32,
}
impl Show {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the Feed `link`.
///
/// Usually the website/homepage of the content creator.
pub fn link(&self) -> &str {
&self.link
}
/// Get the `description`.
pub fn description(&self) -> &str {
&self.description
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
}
}
#[derive(Queryable, Debug, Clone)]
/// Diesel Model of the Show cover query.
/// Used for fetching information about a Show's cover.
pub struct ShowCoverModel {
id: i32,
title: String,
image_uri: Option<String>,
}
impl From<Show> for ShowCoverModel {
fn from(p: Show) -> ShowCoverModel {
ShowCoverModel {
id: p.id(),
title: p.title,
image_uri: p.image_uri,
}
}
}
impl ShowCoverModel {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}

View File

@ -0,0 +1,358 @@
// source.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use diesel::SaveChangesDsl;
// use failure::ResultExt;
use rss::Channel;
use url::Url;
use hyper::client::HttpConnector;
use hyper::{Body, Client};
use hyper_tls::HttpsConnector;
use http::header::{
HeaderValue, AUTHORIZATION, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, LOCATION,
USER_AGENT as USER_AGENT_HEADER,
};
use http::{Request, Response, StatusCode, Uri};
// use futures::future::ok;
use futures::future::{loop_fn, Future, Loop};
use futures::prelude::*;
use base64::{encode_config, URL_SAFE};
use crate::database::connection;
use crate::errors::*;
use crate::feed::{Feed, FeedBuilder};
use crate::models::{NewSource, Save};
use crate::schema::source;
use crate::USER_AGENT;
use std::str::FromStr;
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
#[table_name = "source"]
#[changeset_options(treat_none_as_null = "true")]
#[derive(Debug, Clone)]
/// Diesel Model of the source table.
pub struct Source {
id: i32,
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl Save<Source> for Source {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Source, Self::Error> {
let db = connection();
let con = db.get()?;
self.save_changes::<Source>(&*con).map_err(From::from)
}
}
impl Source {
/// Get the source `id` column.
pub fn id(&self) -> i32 {
self.id
}
/// Represents the location(usually url) of the Feed xml file.
pub fn uri(&self) -> &str {
&self.uri
}
/// Set the `uri` field value.
pub fn set_uri(&mut self, uri: String) {
self.uri = uri;
}
/// Represents the Http Last-Modified Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn last_modified(&self) -> Option<&str> {
self.last_modified.as_ref().map(|s| s.as_str())
}
/// Set `last_modified` value.
pub fn set_last_modified(&mut self, value: Option<String>) {
// self.last_modified = value.map(|x| x.to_string());
self.last_modified = value;
}
/// Represents the Http Etag Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn http_etag(&self) -> Option<&str> {
self.http_etag.as_ref().map(|s| s.as_str())
}
/// Set `http_etag` value.
pub fn set_http_etag(&mut self, value: Option<&str>) {
self.http_etag = value.map(|x| x.to_string());
}
/// Extract Etag and LastModifier from res, and update self and the
/// corresponding db row.
fn update_etag(mut self, res: &Response<Body>) -> Result<Self, DataError> {
let headers = res.headers();
let etag = headers
.get(ETAG)
.and_then(|h| h.to_str().ok())
.map(From::from);
let lmod = headers
.get(LAST_MODIFIED)
.and_then(|h| h.to_str().ok())
.map(From::from);
if (self.http_etag() != etag) || (self.last_modified != lmod) {
self.set_http_etag(etag);
self.set_last_modified(lmod);
self = self.save()?;
}
Ok(self)
}
/// Clear the `HTTP` `Etag` and `Last-modified` headers.
/// This method does not sync the state of self in the database, call
/// .save() method explicitly
fn clear_etags(&mut self) {
debug!("Source etags before clear: {:#?}", &self);
self.http_etag = None;
self.last_modified = None;
}
fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
}
// TODO match on more stuff
// 301: Moved Permanently
// 304: Up to date Feed, checked with the Etag
// 307: Temporary redirect of the url
// 308: Permanent redirect of the url
// 401: Unathorized
// 403: Forbidden
// 408: Timeout
// 410: Feed deleted
// TODO: Rething this api,
fn match_status(mut self, res: Response<Body>) -> Result<Response<Body>, DataError> {
let code = res.status();
if code.is_success() {
// If request is succesful save the etag
self = self.update_etag(&res)?
} else {
match code.as_u16() {
// Save etags if it returns NotModified
304 => self = self.update_etag(&res)?,
// Clear the Etag/lmod else
_ => {
self.clear_etags();
self = self.save()?;
}
};
};
match code.as_u16() {
304 => {
info!("304: Source, (id: {}), is up to date", self.id());
return Err(DataError::FeedNotModified(self));
}
301 | 302 | 308 => {
warn!("Feed was moved permanently.");
self = self.update_url(&res)?;
return Err(DataError::FeedRedirect(self));
}
307 => {
warn!("307: Temporary Redirect.");
// FIXME: How is it actually handling the redirect?
return Err(DataError::FeedRedirect(self));
}
401 => return Err(self.make_err("401: Unauthorized.", code)),
403 => return Err(self.make_err("403: Forbidden.", code)),
404 => return Err(self.make_err("404: Not found.", code)),
408 => return Err(self.make_err("408: Request Timeout.", code)),
410 => return Err(self.make_err("410: Feed was deleted..", code)),
_ => info!("HTTP StatusCode: {}", code),
};
Ok(res)
}
fn update_url(mut self, res: &Response<Body>) -> Result<Self, DataError> {
let code = res.status();
let headers = res.headers();
info!("HTTP StatusCode: {}", code);
debug!("Headers {:#?}", headers);
if let Some(url) = headers.get(LOCATION) {
debug!("Previous Source: {:#?}", &self);
self.set_uri(url.to_str()?.into());
self.clear_etags();
self = self.save()?;
debug!("Updated Source: {:#?}", &self);
info!(
"Feed url of Source {}, was updated successfully.",
self.id()
);
}
Ok(self)
}
/// Construct a new `Source` with the given `uri` and index it.
///
/// This only indexes the `Source` struct, not the Podcast Feed.
pub fn from_url(uri: &str) -> Result<Source, DataError> {
let url = Url::parse(uri)?;
NewSource::new(&url).to_source()
}
/// `Feed` constructor.
///
/// Fetches the latest xml Feed.
///
/// Updates the validator Http Headers.
///
/// Consumes `self` and Returns the corresponding `Feed` Object.
// Refactor into TryInto once it lands on stable.
pub fn into_feed(
self,
client: Client<HttpsConnector<HttpConnector>>,
) -> impl Future<Item = Feed, Error = DataError> {
let id = self.id();
let response = loop_fn(self, move |source| {
source
.request_constructor(&client.clone())
.then(|res| match res {
Ok(response) => Ok(Loop::Break(response)),
Err(err) => match err {
DataError::FeedRedirect(s) => {
info!("Following redirect...");
Ok(Loop::Continue(s))
}
e => Err(e),
},
})
});
response
.and_then(response_to_channel)
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.map_err(From::from)
})
}
fn request_constructor(
self,
client: &Client<HttpsConnector<HttpConnector>>,
) -> impl Future<Item = Response<Body>, Error = DataError> {
// FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::get(uri).body(Body::empty()).unwrap();
if let Ok(url) = Url::parse(self.uri()) {
if let Some(password) = url.password() {
let mut auth = "Basic ".to_owned();
auth.push_str(&encode_config(
&format!("{}:{}", url.username(), password),
URL_SAFE,
));
req.headers_mut()
.insert(AUTHORIZATION, HeaderValue::from_str(&auth).unwrap());
}
}
// Set the UserAgent cause ppl still seem to check it for some reason...
req.headers_mut()
.insert(USER_AGENT_HEADER, HeaderValue::from_static(USER_AGENT));
if let Some(etag) = self.http_etag() {
req.headers_mut()
.insert(IF_NONE_MATCH, HeaderValue::from_str(etag).unwrap());
}
if let Some(lmod) = self.last_modified() {
req.headers_mut()
.insert(IF_MODIFIED_SINCE, HeaderValue::from_str(lmod).unwrap());
}
client
.request(req)
.map_err(From::from)
.and_then(move |res| self.match_status(res))
}
}
fn response_to_channel(
res: Response<Body>,
) -> impl Future<Item = Channel, Error = DataError> + Send {
res.into_body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
}
#[cfg(test)]
mod tests {
use super::*;
use failure::Error;
use num_cpus;
use tokio;
use crate::database::truncate_db;
use crate::utils::get_feed;
#[test]
fn test_into_feed() -> Result<(), Error> {
truncate_db()?;
let mut rt = tokio::runtime::Runtime::new()?;
let https = HttpsConnector::new(num_cpus::get())?;
let client = Client::builder().build::<_, Body>(https);
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url)?;
let id = source.id();
let feed = source.into_feed(client);
let feed = rt.block_on(feed)?;
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
assert_eq!(expected, feed);
Ok(())
}
}

355
podcasts-data/src/opml.rs Normal file
View File

@ -0,0 +1,355 @@
// opml.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! FIXME: Docs
// #![allow(unused)]
use crate::dbqueries;
use crate::errors::DataError;
use crate::models::Source;
use xml::{
common::XmlVersion,
reader,
writer::{events::XmlEvent, EmitterConfig},
};
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use std::fs::File;
// use std::io::BufReader;
use failure::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
// FIXME: Make it a Diesel model
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
/// not `RSS` related sub-elements are omitted.
///
/// [spec]: http://dev.opml.org/spec2.html
pub struct Opml {
title: String,
description: String,
url: String,
}
/// Import feed url's from a `R` into the `Source` table.
// TODO: Write test
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
let feeds = extract_sources(reader)?
.iter()
.map(|opml| Source::from_url(&opml.url))
.filter_map(|s| {
if let Err(ref err) = s {
let txt = "If you think this might be a bug please consider filling a report over \
at https://gitlab.gnome.org/World/podcasts/issues/new";
error!("Failed to import a Show: {}", err);
error!("{}", txt);
}
s.ok()
})
.collect();
Ok(feeds)
}
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
/// return the new `Source`s
// TODO: Write test
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
let content = fs::read(path)?;
import_to_db(content.as_slice()).map_err(From::from)
}
/// Export a file to `P`, taking the feeds from the database and outputting
/// them in opml format.
pub fn export_from_db<P: AsRef<Path>>(path: P, export_title: &str) -> Result<(), Error> {
let file = File::create(path)?;
export_to_file(&file, export_title)
}
/// Export from `Source`s and `Show`s into `F` in OPML format
pub fn export_to_file<F: Write>(file: F, export_title: &str) -> Result<(), Error> {
let config = EmitterConfig::new().perform_indent(true);
let mut writer = config.create_writer(file);
let mut events: Vec<XmlEvent<'_>> = Vec::new();
// Set up headers
let doc = XmlEvent::StartDocument {
version: XmlVersion::Version10,
encoding: Some("UTF-8"),
standalone: Some(false),
};
events.push(doc);
let opml: XmlEvent<'_> = XmlEvent::start_element("opml")
.attr("version", "2.0")
.into();
events.push(opml);
let head: XmlEvent<'_> = XmlEvent::start_element("head").into();
events.push(head);
let title_ev: XmlEvent<'_> = XmlEvent::start_element("title").into();
events.push(title_ev);
let title_chars: XmlEvent<'_> = XmlEvent::characters(export_title).into();
events.push(title_chars);
// Close <title> & <head>
events.push(XmlEvent::end_element().into());
events.push(XmlEvent::end_element().into());
let body: XmlEvent<'_> = XmlEvent::start_element("body").into();
events.push(body);
for event in events {
writer.write(event)?;
}
// FIXME: Make this a model of a joined query (http://docs.diesel.rs/diesel/macro.joinable.html)
let shows = dbqueries::get_podcasts()?.into_iter().map(|show| {
let source = dbqueries::get_source_from_id(show.source_id()).unwrap();
(source, show)
});
for (ref source, ref show) in shows {
let title = show.title();
let link = show.link();
let xml_url = source.uri();
let s_ev: XmlEvent<'_> = XmlEvent::start_element("outline")
.attr("text", title)
.attr("title", title)
.attr("type", "rss")
.attr("xmlUrl", xml_url)
.attr("htmlUrl", link)
.into();
let end_ev: XmlEvent<'_> = XmlEvent::end_element().into();
writer.write(s_ev)?;
writer.write(end_ev)?;
}
// Close <body> and <opml>
let end_bod: XmlEvent<'_> = XmlEvent::end_element().into();
writer.write(end_bod)?;
let end_opml: XmlEvent<'_> = XmlEvent::end_element().into();
writer.write(end_opml)?;
Ok(())
}
/// Extracts the `outline` elements from a reader `R` and returns a `HashSet` of `Opml` structs.
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
let mut list = HashSet::new();
let parser = reader::EventReader::new(reader);
parser
.into_iter()
.map(|e| match e {
Ok(reader::XmlEvent::StartElement {
name, attributes, ..
}) => {
if name.local_name == "outline" {
let mut title = String::new();
let mut url = String::new();
let mut description = String::new();
attributes.into_iter().for_each(|attribute| {
match attribute.name.local_name.as_str() {
"title" => title = attribute.value,
"xmlUrl" => url = attribute.value,
"description" => description = attribute.value,
_ => {}
}
});
let feed = Opml {
title,
description,
url,
};
list.insert(feed);
}
Ok(())
}
Err(err) => Err(err),
_ => Ok(()),
})
.collect::<Result<Vec<_>, reader::Error>>()?;
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
use failure::Error;
use futures::Future;
use crate::database::{truncate_db, TEMPDIR};
use crate::utils::get_feed;
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
(
"tests/feeds/2019-01-27-ACC.xml",
"https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss"
),
]
};
#[test]
fn test_extract() -> Result<(), Error> {
let int_title = String::from("Intercepted with Jeremy Scahill");
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
let int_desc = String::from(
"The people behind The Intercepts fearless reporting and incisive \
commentaryJeremy Scahill, Glenn Greenwald, Betsy Reed and othersdiscuss 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 \
Americanand globalpolitics.",
);
#[cfg_attr(rustfmt, rustfmt_skip)]
let sample1 = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
<opml version=\"2.0\"> \
<head> \
<title>Test OPML File</title> \
<dateCreated>{}</dateCreated> \
<docs>http://www.opml.org/spec2</docs> \
</head> \
<body> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
</body> \
</opml>",
Local::now().format("%a, %d %b %Y %T %Z"),
int_title,
int_desc,
int_url,
dec_title,
dec_desc,
dec_url,
);
let map = hashset![
Opml {
title: int_title,
description: int_desc,
url: int_url
},
Opml {
title: dec_title,
description: dec_desc,
url: dec_url
},
];
assert_eq!(extract_sources(sample1.as_bytes())?, map);
Ok(())
}
#[test]
fn text_export() -> Result<(), Error> {
truncate_db()?;
URLS.iter().for_each(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
let feed = get_feed(path, s.id());
feed.index().wait().unwrap();
});
let mut map: HashSet<Opml> = HashSet::new();
let shows = dbqueries::get_podcasts()?.into_iter().map(|show| {
let source = dbqueries::get_source_from_id(show.source_id()).unwrap();
(source, show)
});
for (ref source, ref show) in shows {
let title = show.title().to_string();
// description is an optional field that we don't export
let description = String::new();
let url = source.uri().to_string();
map.insert(Opml {
title,
description,
url,
});
}
let opml_path = TEMPDIR.path().join("podcasts.opml");
export_from_db(opml_path.as_path(), "GNOME Podcasts Subscriptions")?;
let opml_file = File::open(opml_path.as_path())?;
assert_eq!(extract_sources(&opml_file)?, map);
// extract_sources drains the reader its passed
let mut opml_file = File::open(opml_path.as_path())?;
let mut opml_str = String::new();
opml_file.read_to_string(&mut opml_str)?;
assert_eq!(opml_str, include_str!("../tests/export_test.opml"));
Ok(())
}
}

View File

@ -1,3 +1,22 @@
// parser.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use rss::extension::itunes::ITunesItemExtension;
/// Parses an Item Itunes extension and returns it's duration value in seconds.
@ -77,5 +96,4 @@ mod tests {
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(6970));
}
}

View File

@ -0,0 +1,133 @@
// pipeline.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// FIXME:
//! Docs.
use futures::{future::ok, lazy, prelude::*, stream::FuturesUnordered};
use tokio;
use hyper::client::HttpConnector;
use hyper::{Body, Client};
use hyper_tls::HttpsConnector;
use num_cpus;
use crate::errors::DataError;
use crate::Source;
use std::iter::FromIterator;
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`.
///
/// Messy temp diagram:
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
pub fn pipeline<'a, S>(sources: S, client: HttpsClient) -> impl Future<Item = (), Error = ()> + 'a
where
S: Stream<Item = Source, Error = DataError> + Send + 'a,
{
sources
.and_then(move |s| s.into_feed(client.clone()))
.map_err(|err| {
match err {
// Avoid spamming the stderr when its not an eactual error
DataError::FeedNotModified(_) => (),
_ => error!("Error: {}", err),
}
})
.and_then(move |feed| {
let fut = lazy(|| feed.index().map_err(|err| error!("Error: {}", err)));
tokio::spawn(fut);
Ok(())
})
// For each terminates the stream at the first error so we make sure
// we pass good values regardless
.then(move |_| ok(()))
// Convert the stream into a Future to later execute as a tokio task
.for_each(move |_| ok(()))
}
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
pub fn run<S>(sources: S) -> Result<(), DataError>
where
S: IntoIterator<Item = Source>,
{
let https = HttpsConnector::new(num_cpus::get())?;
let client = Client::builder().build::<_, Body>(https);
let foo = sources.into_iter().map(ok::<_, _>);
let stream = FuturesUnordered::from_iter(foo);
let p = pipeline(stream, client);
tokio::run(p);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::truncate_db;
use crate::dbqueries;
use crate::Source;
use failure::Error;
// (path, url) tuples.
const URLS: &[&str] = &[
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
];
#[test]
/// Insert feeds and update/index them.
fn test_pipeline() -> Result<(), Error> {
truncate_db()?;
let bad_url = "https://gitlab.gnome.org/World/podcasts.atom";
// if a stream returns error/None it stops
// bad we want to parse all feeds regardless if one fails
Source::from_url(bad_url)?;
URLS.iter().for_each(|url| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
let sources = dbqueries::get_sources()?;
run(sources)?;
let sources = dbqueries::get_sources()?;
// Run again to cover Unique constrains erros.
run(sources)?;
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources()?.len(), 6);
assert_eq!(dbqueries::get_podcasts()?.len(), 5);
assert_eq!(dbqueries::get_episodes()?.len(), 354);
Ok(())
}
}

View File

@ -0,0 +1,29 @@
diff --git a/podcasts-data/src/schema.rs b/podcasts-data/src/schema.rs
index 03cbed0..88f1622 100644
--- a/podcasts-data/src/schema.rs
+++ b/podcasts-data/src/schema.rs
@@ -1,8 +1,11 @@
+#![allow(warnings)]
+
table! {
episodes (title, show_id) {
+ rowid -> Integer,
title -> Text,
uri -> Nullable<Text>,
local_uri -> Nullable<Text>,
description -> Nullable<Text>,
epoch -> Integer,
length -> Nullable<Integer>,
@@ -30,11 +33,7 @@ table! {
uri -> Text,
last_modified -> Nullable<Text>,
http_etag -> Nullable<Text>,
}
}
-allow_tables_to_appear_in_same_query!(
- episodes,
- shows,
- source,
-);
+allow_tables_to_appear_in_same_query!(episodes, shows, source);

View File

@ -1,5 +1,7 @@
#![allow(warnings)]
table! {
episode (title, podcast_id) {
episodes (title, show_id) {
rowid -> Integer,
title -> Text,
uri -> Nullable<Text>,
@ -10,22 +12,17 @@ table! {
duration -> Nullable<Integer>,
guid -> Nullable<Text>,
played -> Nullable<Integer>,
favorite -> Bool,
archive -> Bool,
podcast_id -> Integer,
show_id -> Integer,
}
}
table! {
podcast (id) {
shows (id) {
id -> Integer,
title -> Text,
link -> Text,
description -> Text,
image_uri -> Nullable<Text>,
favorite -> Bool,
archive -> Bool,
always_dl -> Bool,
source_id -> Integer,
}
}
@ -39,4 +36,4 @@ table! {
}
}
allow_tables_to_appear_in_same_query!(episode, podcast, source,);
allow_tables_to_appear_in_same_query!(episodes, shows, source);

View File

@ -1,3 +1,22 @@
// utils.rs
//
// Copyright 2017 Jordan Petridis <jpetridis@gnome.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Helper utilities for accomplishing various tasks.
use chrono::prelude::*;
@ -5,10 +24,10 @@ use rayon::prelude::*;
use url::{Position, Url};
use dbqueries;
use errors::DataError;
use models::{EpisodeCleanerQuery, Podcast, Save};
use xdg_dirs::DL_DIR;
use crate::dbqueries;
use crate::errors::DataError;
use crate::models::{EpisodeCleanerModel, Save, Show};
use crate::xdg_dirs::DL_DIR;
use std::fs;
use std::path::Path;
@ -28,16 +47,16 @@ fn download_checker() -> Result<(), DataError> {
})
.for_each(|ep| {
ep.set_local_uri(None);
if let Err(err) = ep.save() {
error!("Error while trying to update episode: {:#?}", ep);
error!("{}", err);
};
ep.save()
.map_err(|err| error!("{}", err))
.map_err(|_| error!("Error while trying to update episode: {:#?}", ep))
.ok();
});
Ok(())
}
/// Delete watched `episodes` that have exceded their liftime after played.
/// Delete watched `episodes` that have exceeded their lifetime after played.
fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
let mut episodes = dbqueries::get_played_cleaner_episodes()?;
let now_utc = cleanup_date.timestamp() as i32;
@ -48,19 +67,18 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
.for_each(|ep| {
let limit = ep.played().unwrap();
if now_utc > limit {
if let Err(err) = delete_local_content(ep) {
error!("Error while trying to delete file: {:?}", ep.local_uri());
error!("{}", err);
} else {
info!("Episode {:?} was deleted succesfully.", ep.local_uri());
};
delete_local_content(ep)
.map(|_| info!("Episode {:?} was deleted successfully.", ep.local_uri()))
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri()))
.ok();
}
});
Ok(())
}
/// Check `ep.local_uri` field and delete the file it points to.
fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
fn delete_local_content(ep: &mut EpisodeCleanerModel) -> Result<(), DataError> {
if ep.local_uri().is_some() {
let uri = ep.local_uri().unwrap().to_owned();
if Path::new(&uri).exists() {
@ -104,14 +122,14 @@ pub fn url_cleaner(s: &str) -> String {
// https://rust-lang-nursery.github.io/rust-cookbook/net.html
// #remove-fragment-identifiers-and-query-pairs-from-a-url
match Url::parse(s) {
Ok(parsed) => parsed[..Position::AfterPath].to_owned(),
Ok(parsed) => parsed[..Position::AfterQuery].to_owned(),
_ => s.trim().to_owned(),
}
}
/// Returns the URI of a Podcast Downloads given it's title.
/// Returns the URI of a Show Downloads given it's title.
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
// It might be better to make it a hash of the title or the podcast rowid
// It might be better to make it a hash of the title or the Show rowid
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
// Create the folder
@ -124,26 +142,25 @@ pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
/// Removes all the entries associated with the given show from the database,
/// and deletes all of the downloaded content.
// TODO: Write Tests
pub fn delete_show(pd: &Podcast) -> Result<(), DataError> {
pub fn delete_show(pd: &Show) -> Result<(), DataError> {
dbqueries::remove_feed(pd)?;
info!("{} was removed succesfully.", pd.title());
info!("{} was removed successfully.", pd.title());
let fold = get_download_folder(pd.title())?;
fs::remove_dir_all(&fold)?;
info!("All the content at, {} was removed succesfully", &fold);
info!("All the content at, {} was removed successfully", &fold);
Ok(())
}
#[cfg(test)]
use Feed;
use crate::Feed;
#[cfg(test)]
/// Helper function that open a local file, parse the rss::Channel and gives back a Feed object.
/// Alternative Feed constructor to be used for tests.
pub fn get_feed(file_path: &str, id: i32) -> Feed {
use feed::FeedBuilder;
use crate::feed::FeedBuilder;
use rss::Channel;
use std::fs;
use std::io::BufReader;
// open the xml file
@ -159,61 +176,58 @@ pub fn get_feed(file_path: &str, id: i32) -> Feed {
#[cfg(test)]
mod tests {
extern crate tempdir;
use self::tempdir::TempDir;
use super::*;
use chrono::Duration;
use failure::Error;
use tempdir::TempDir;
use database::truncate_db;
use models::NewEpisodeBuilder;
use crate::database::truncate_db;
use crate::models::NewEpisodeBuilder;
use std::fs::File;
use std::io::Write;
fn helper_db() -> TempDir {
fn helper_db() -> Result<TempDir, Error> {
// Clean the db
truncate_db().unwrap();
truncate_db()?;
// Setup tmp file stuff
let tmp_dir = TempDir::new("hammond_test").unwrap();
let tmp_dir = TempDir::new("podcasts_test")?;
let valid_path = tmp_dir.path().join("virtual_dl.mp3");
let bad_path = tmp_dir.path().join("invalid_thing.mp3");
let mut tmp_file = File::create(&valid_path).unwrap();
writeln!(tmp_file, "Foooo").unwrap();
let mut tmp_file = File::create(&valid_path)?;
writeln!(tmp_file, "Foooo")?;
// Setup episodes
let n1 = NewEpisodeBuilder::default()
.title("foo_bar".to_string())
.podcast_id(0)
.show_id(0)
.build()
.unwrap()
.to_episode()
.unwrap();
.to_episode()?;
let n2 = NewEpisodeBuilder::default()
.title("bar_baz".to_string())
.podcast_id(1)
.show_id(1)
.build()
.unwrap()
.to_episode()
.unwrap();
.to_episode()?;
let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.podcast_id()).unwrap();
let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.podcast_id()).unwrap();
let mut ep1 = dbqueries::get_episode_cleaner_from_pk(n1.title(), n1.show_id())?;
let mut ep2 = dbqueries::get_episode_cleaner_from_pk(n2.title(), n2.show_id())?;
ep1.set_local_uri(Some(valid_path.to_str().unwrap()));
ep2.set_local_uri(Some(bad_path.to_str().unwrap()));
ep1.save().unwrap();
ep2.save().unwrap();
ep1.save()?;
ep2.save()?;
tmp_dir
Ok(tmp_dir)
}
#[test]
fn test_download_checker() {
let tmp_dir = helper_db();
download_checker().unwrap();
let episodes = dbqueries::get_downloaded_episodes().unwrap();
fn test_download_checker() -> Result<(), Error> {
let tmp_dir = helper_db()?;
download_checker()?;
let episodes = dbqueries::get_downloaded_episodes()?;
let valid_path = tmp_dir.path().join("virtual_dl.mp3");
assert_eq!(episodes.len(), 1);
@ -222,69 +236,75 @@ mod tests {
episodes.first().unwrap().local_uri()
);
let _tmp_dir = helper_db();
download_checker().unwrap();
let episode = dbqueries::get_episode_from_pk("bar_baz", 1).unwrap();
let _tmp_dir = helper_db()?;
download_checker()?;
let episode = dbqueries::get_episode_cleaner_from_pk("bar_baz", 1)?;
assert!(episode.local_uri().is_none());
Ok(())
}
#[test]
fn test_download_cleaner() {
let _tmp_dir = helper_db();
let mut episode: EpisodeCleanerQuery =
dbqueries::get_episode_from_pk("foo_bar", 0).unwrap().into();
fn test_download_cleaner() -> Result<(), Error> {
let _tmp_dir = helper_db()?;
let mut episode: EpisodeCleanerModel =
dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?.into();
let valid_path = episode.local_uri().unwrap().to_owned();
delete_local_content(&mut episode).unwrap();
delete_local_content(&mut episode)?;
assert_eq!(Path::new(&valid_path).exists(), false);
Ok(())
}
#[test]
fn test_played_cleaner_expired() {
let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
fn test_played_cleaner_expired() -> Result<(), Error> {
let _tmp_dir = helper_db()?;
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?;
let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 - 1;
episode.set_played(Some(epoch));
episode.save().unwrap();
episode.save()?;
let valid_path = episode.local_uri().unwrap().to_owned();
// This should delete the file
played_cleaner(cleanup_date).unwrap();
played_cleaner(cleanup_date)?;
assert_eq!(Path::new(&valid_path).exists(), false);
Ok(())
}
#[test]
fn test_played_cleaner_none() {
let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
fn test_played_cleaner_none() -> Result<(), Error> {
let _tmp_dir = helper_db()?;
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?;
let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 + 1;
episode.set_played(Some(epoch));
episode.save().unwrap();
episode.save()?;
let valid_path = episode.local_uri().unwrap().to_owned();
// This should not delete the file
played_cleaner(cleanup_date).unwrap();
played_cleaner(cleanup_date)?;
assert_eq!(Path::new(&valid_path).exists(), true);
Ok(())
}
#[test]
fn test_url_cleaner() {
let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3";
let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184";
fn test_url_cleaner() -> Result<(), Error> {
let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184";
let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184#foobar";
assert_eq!(url_cleaner(bad_url), good_url);
assert_eq!(url_cleaner(good_url), good_url);
assert_eq!(url_cleaner(&format!(" {}\t\n", bad_url)), good_url);
Ok(())
}
#[test]
// This test needs access to local system so we ignore it by default.
#[ignore]
fn test_get_dl_folder() {
fn test_get_dl_folder() -> Result<(), Error> {
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
assert_eq!(get_download_folder("foo").unwrap(), foo_);
assert_eq!(get_download_folder("foo")?, foo_);
let _ = fs::remove_dir_all(foo_);
Ok(())
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<opml version="2.0">
<head>
<title>GNOME Podcasts Subscriptions</title>
</head>
<body>
<outline text="David Harvey&apos;s Anti-Capitalist Chronicles" title="David Harvey&apos;s Anti-Capitalist Chronicles" type="rss" xmlUrl="https://web.archive.org/web/20190127005213if_/https://anticapitalistchronicles.libsyn.com/rss" htmlUrl="https://www.democracyatwork.info/acc" />
<outline text="Greater Than Code" title="Greater Than Code" type="rss" xmlUrl="https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast" htmlUrl="https://www.greaterthancode.com/" />
<outline text="Intercepted with Jeremy Scahill" title="Intercepted with Jeremy Scahill" type="rss" xmlUrl="https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.com/InterceptedWithJeremyScahill" htmlUrl="https://theintercept.com/podcasts" />
<outline text="LINUX Unplugged Podcast" title="LINUX Unplugged Podcast" type="rss" xmlUrl="https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged" htmlUrl="http://www.jupiterbroadcasting.com/" />
<outline text="Steal the Stars" title="Steal the Stars" type="rss" xmlUrl="https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars" htmlUrl="http://tor-labs.com/" />
<outline text="The Tip Off" title="The Tip Off" type="rss" xmlUrl="https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff" htmlUrl="http://www.acast.com/thetipoff" />
</body>
</opml>

Some files were not shown because too many files have changed in this diff Show More