Author SHA1 Message Date
Jordan Petridis cd52c5afdf AboutEpisode: Initial commit. 2018-06-06 14:13:27 +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.
https://gitlab.gnome.org/GNOME/nautilus/commit/3a35b6035ae6f383744aa88b73e451918292736c

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
Jordan Petridis d332636dd4 Fix the fix that should have fixed the tests. 2018-04-06 19:36:52 +03:00
Jordan Petridis 32654f6cb2 Fix the tests. 2018-04-06 19:18:55 +03:00
Jordan Petridis 14a90e7138 Remove Futures_Cpupool.
The performance boost is not good enough to justify the
code complexity it add and the memory overhead of
yeat another threadpool.

We will start refactoring the whole pipeline implemantation
and might transition to either rayon-futures or tokio-runtime.
2018-04-06 18:18:03 +03:00
Jordan Petridis dd2366a15e Change the git url of the html2pango crate. 2018-04-05 20:44:28 +03:00
Jordan Petridis 370ba2d461 dlmanager: minor cleanup. 2018-04-04 21:36:23 +03:00
Jordan Petridis ef655ef5e0 EpisodeWidget: Keep the widget's heigth contant. Fixes #57 2018-04-04 18:44:23 +03:00
Jordan Petridis 5cc550c830 NewEpisode: refactor a closure to that returned Option<T> to use .and_then instead. 2018-04-04 16:32:03 +03:00
Jordan Petridis 52cbe67756 NewEpisode: refactor another if else statement and document it. 2018-04-04 16:31:59 +03:00
Jordan Petridis c910e0af40 NewPodcast: refactor an if else statement and document it. 2018-04-04 16:31:50 +03:00
Jordan Petridis 7a74534285 Merge branch '25-render-show-description' into 'master'
Resolve "Render link/bold/url attributes in ShowWidget description"

Closes #25

See merge request alatiera/hammond!30
2018-04-03 19:51:49 +00:00
Jordan Petridis 916775462d Update changelog. 2018-04-03 19:42:13 +00:00
Jordan Petridis 3d98600126 h-data: Sanitize html during Podcast/Episode parsing. 2018-04-03 19:42:13 +00:00
Jordan Petridis 7ba834ee8d Update deps now that we are at it. 2018-04-03 19:42:12 +00:00
Jordan Petridis 1c527cba03 Remove more commented out dead code. 2018-04-03 19:42:12 +00:00
Jordan Petridis 2d7ba7b246 h-data/source.rs: Reduce boilerplate. 2018-04-03 19:42:11 +00:00
Jordan Petridis 4ed70a8011 Rss::Error is now Send! 2018-04-03 19:42:11 +00:00
Jordan Petridis 7a3a148359 Remove more dead code. 2018-04-03 19:42:11 +00:00
Jordan Petridis e07e35110d Use pretty assertions! 2018-04-03 19:42:10 +00:00
Jordan Petridis a463753c84 NewEpidode: Use parse rss.description instead of itunes.summary.
We can deal with(sort of) html now, so we should start indexing
the proper rss description. Also cleanup commented out code.
2018-04-03 19:42:10 +00:00
Jordan Petridis a946ddfab1 html_to_pango: Switch to use the new library spawn from this.
Thanks to @danigm for spinning that part of fractal to a shared library.
2018-04-03 19:42:09 +00:00
Jordan Petridis af1cb43bd6 NewPodcast: Prefer the rss.description attribute.
Since we can handle rendering html stuff by converting it to pango
we no longer need the text-only itunes summary attribure.
2018-04-03 19:42:09 +00:00
Jordan Petridis 935d61324f ShowWidget: Convert html to pango markup and render it.
Instead of stipping all the html tags and just using the text
in the label we could *try* converting it to pango markup
which is a bit more flexible than plain text.

The code was copied from Fractal.
2018-04-03 19:42:08 +00:00
Jordan Petridis 10e016f2ea update appdata.xml 2018-04-03 21:41:53 +03:00
Jordan Petridis 154655d571 Readme: Fix the dependancies listed to include rustc and cargo. 2018-04-03 20:49:30 +03:00
Jordan Petridis 491cd8f01c cargo fmt 2018-03-30 17:24:38 +03:00
Jordan Petridis 2b711ff04c Update .doap file 2018-03-30 16:47:09 +03:00
Jordan Petridis 07eadd2364 h-gtk/utils: Improve itunes resolver and add extra test cases. 2018-03-30 16:38:59 +03:00
Jordan Petridis 7086afe73d h-gtk/utils: More refactor to improve formatting. 2018-03-30 15:33:19 +03:00
Jordan Petridis f21398357b h-gtk/utils: Refactor some mutex locks, improve formatting. 2018-03-30 14:39:30 +03:00
Jordan Petridis c338802329 Update deps. 2018-03-30 11:49:54 +03:00
Jordan Petridis c3076e748e cargo fmt 2018-03-30 10:22:34 +03:00
Jordan Petridis 1595256c86 Use rayon to manage all the threads. 2018-03-30 09:31:25 +03:00
Jordan Petridis 0623592f75 Merge branch '7-async-image-loading' into 'master'
Resolve "Make image loading not blocking the programs execution."

Closes #7

See merge request alatiera/Hammond!29
2018-03-29 14:57:44 +00:00
Jordan Petridis f2444f151c h-gtk/utils: Re-work format_err! calls and improve formatting 2018-03-29 15:26:44 +03:00
Jordan Petridis 710a3f2552 Use SendCell::try_get instead of SendCell::into_inner 2018-03-29 15:19:13 +03:00
Jordan Petridis 6071c664e7 Update changelog 2018-03-29 13:32:57 +03:00
Jordan Petridis e203815f4f hammond-gtk/utils.rs: Use a hashset to keep track of cover downloads.
Use a HashSet to check if a download of a cover is already active. If
it is, schedule a callback that will try to set the image from the
cached pixbuf later.
2018-03-29 13:07:46 +03:00
Jordan Petridis c3658080d3 Comment out a test. 2018-03-29 11:37:31 +03:00
Jordan Petridis 8703470010 h-gtk/utils: Use a threadpool to avoid spawning a million threads 2018-03-29 10:24:02 +03:00
Jordan Petridis 88cc7e6fec Fix set_image_from_path test 2018-03-29 09:21:49 +03:00
Jordan Petridis badcbc32c6 Implement async loading of the Show covers. 2018-03-28 22:41:45 +03:00
Jordan Petridis daa8f15ce9 hammond-gtk::utils: change the signature of get_pixbug_from_path and rename it
Requires a gtk::Image as argument now, it sets the pixbuf to the
img directly instead of returning it.
New name is set_image_from_path.

This is ground work so we can later keep the image reference, and
use it to set the image with a callback.
2018-03-28 21:47:10 +03:00
Jordan Petridis 89ee174ded Version bump. 2018-03-28 14:48:43 +03:00
Jordan Petridis cc03c2407b Merge branch '44-empty-show-widget' into 'master'
Resolve "Unpleasant ShowWidget behaviour if the show has no Episodes"

Closes #44

See merge request alatiera/Hammond!28
2018-03-28 11:25:35 +00:00
Jordan Petridis 89254025f3 Update changelog 2018-03-28 13:33:29 +03:00
Jordan Petridis f693c986ec Add an empty_show if Show has no episodes. 2018-03-28 13:24:26 +03:00
Jordan Petridis 3c7f3ecb56 NewPodcast: Fix Image parsing if Itunes ext is Some(None).
Instead of checking if the itunes img url was Some we were assuming
that if an itunes extension existed, it would have an image. That's
not always the case as it turns out there can be an Itunes Ext but
img still be None resulting to not falling back to the Rss image tag.
2018-03-28 12:08:41 +03:00
Jordan Petridis ef3809ed25 Update about dialog. 2018-03-27 16:53:12 +03:00
Jordan Petridis f5ddb7107e Update changelog 2018-03-27 16:43:06 +03:00
Jordan Petridis 1d32018c02 Merge branch 'feature/persist-window-geometry' into 'master'
Issue #50: Persist window geometry

See merge request alatiera/Hammond!25
2018-03-27 12:27:33 +00:00
Rowan Lewis c458b27573 Handle window geometry with a new struct. 2018-03-27 11:54:36 +00:00
Rowan Lewis 524e0bb0a8 Persist window geometry including maximized state for issue #50. 2018-03-27 11:54:36 +00:00
Jordan Petridis d525d1fe59 InAppNofitication: Make revealer field private, change show signature
Accept an overlay widget that the revealer will be attached to into
the show method. Thus revealer field no longer need to be public.
2018-03-27 12:01:53 +03:00
Jordan Petridis bdc6264701 app.rs: Minor formatting improvments. 2018-03-27 11:50:31 +03:00
Jordan Petridis 7e2640e2d0 ShowWidget: When unsub notification expires, remove show from the ignore list.
This should not make any difference regarding the behaviour since
the Show id is eq to the sqlite rowid which means that even
if the same show was removed and readded it would have diff id.
2018-03-27 11:09:53 +03:00
Jordan Petridis 822a72efde gitlabci: Enable the ubuntu/rust stable build, disable tests in flatpak
Due to meson not understanding cargo, it's actually faster to have
a separate build + test job than trying to compile the cargo
tests twice inside the flatpak enviroment
2018-03-27 10:04:02 +03:00
Jordan Petridis 192b13e393 Merge branch 'issue/52' into 'master'
Fix #52: Double border around main window

Closes #52

See merge request alatiera/Hammond!24
2018-03-26 19:21:04 +00:00
Rowan Lewis 2497cb31d0 Remove shadow_type from the show widget. 2018-03-26 18:53:48 +00:00
Rowan Lewis 446a0ede54 Fix #52 by removing the specified shadow_type from the episodes and shows scrolled windows. 2018-03-26 18:53:47 +00:00
Jordan Petridis 3d39638c99 Merge branch '36-add-undo-for-unsubscribing-from-shows' into 'master'
Resolve "Add "undo" for unsubscribing from shows"

Closes #36

See merge request alatiera/Hammond!27
2018-03-26 18:39:19 +00:00
Jordan Petridis 69e87d129a ShowWidget: Hide shows from the Views when unsub is hit. 2018-03-26 14:34:54 +03:00
Jordan Petridis f7a7510322 Implement the shared HashSet with the ignored Shows ids 2018-03-26 13:25:39 +03:00
Jordan Petridis e9f2ba47f2 dbquerries: Add get_episodes and get_podcasts querries that can filters. 2018-03-26 12:46:13 +03:00
Jordan Petridis b2c95e5a73 ShowWidget: display a notification before removing the show. 2018-03-26 10:57:44 +03:00
Jordan Petridis 482ed7c3c6 Update README 2018-03-26 10:11:43 +03:00
Jordan Petridis e497f73316 gitlabci: run glib-compile-resources from the flatpak enviroment 2018-03-21 14:24:56 +02:00
Jordan Petridis 28965dc2b1 gitlabci: Disable normal builds now that tests can run on the flatpak one. 2018-03-19 12:00:53 +02:00
Jordan Petridis 5425ca35b3 Cleanup .gitignore a bit 2018-03-19 11:52:02 +02:00
Jordan Petridis 9f191d0ab8 gitlabci: Run cargo test inside the flatpak. 2018-03-19 11:33:45 +02:00
Jordan Petridis c2a3ce5096 Why the hell you do not cache cargo. 2018-03-19 07:43:04 +02:00
Jordan Petridis 1af06f2e0d Neaw gtk-rs release, yay! 2018-03-19 06:34:28 +02:00
Jordan Petridis 2f2f11b7bc gitlabci: Fix flatpak issue building only master. 2018-03-18 17:03:16 +02:00
Jordan Petridis 260e6015a1 Merge branch 'issue/53' into 'master'
Fix #53 by setting the HTTP user agent string to the latest Firefox ESR.

Closes #53

See merge request alatiera/Hammond!26
2018-03-18 11:34:12 +00:00
Rowan Lewis 7696014545 Fix #53 by setting the HTTP user agent string to the latest Firefox ESR. 2018-03-18 11:57:41 +01:00
Jordan Petridis 2457e95f0e gitlabci: Improve caching 2018-03-18 05:59:35 +02:00
Jordan Petridis 327c907463 gitlabci: try to cache the flatpak build 2018-03-18 05:07:42 +02:00
Jordan Petridis 1b558d3b30 gitlabci: switch flatpak build to the custom image 2018-03-18 05:07:37 +02:00
Jordan Petridis a0d55417cd Merge branch 'feature/gsettings-integration' into 'master'
Integrate gsettings into application

See merge request alatiera/Hammond!23
2018-03-17 23:37:43 +00:00
Rowan Lewis f182d441d1 Allow the Copy trait to do the work for us. 2018-03-18 00:29:52 +01:00
Rowan Lewis e14f215793 Allow cleanup to be blocking on startup and remove cleanup from automatic content refreshes. 2018-03-17 23:50:59 +01:00
Rowan Lewis 34536f4e21 Set dark theme at application startup based on settings. 2018-03-17 23:46:37 +01:00
Jordan Petridis bc2da6e59e gitlabci: add flatpak build and reformat tabs. 2018-03-18 00:31:15 +02:00
Rowan Lewis afdedc7575 Use crono types instead of unsigned integers for time periods. 2018-03-17 01:10:10 +01:00
Rowan Lewis 69a7a9b180 Renamed the 'auto-refresh' settings to 'refresh-interval' so that they represent the internal behaviour better. 2018-03-16 23:31:12 +01:00
Rowan Lewis a7540583d6 Connect settings for auto refresh and cleanup. 2018-03-16 23:23:06 +01:00
Jordan Petridis a253d7ebf5 Merge branch 'feature/gsettings-schema' into 'master'
Added initial settings schema.

See merge request alatiera/Hammond!22
2018-03-16 20:05:19 +00:00
Rowan Lewis 3886402f8e Spelling correction. 2018-03-16 20:45:40 +01:00
Rowan Lewis 97e402b980 Added initial settings schema. 2018-03-16 20:38:37 +01:00
Jordan Petridis 93e15af209 Compress gresource ui files. 2018-03-16 21:29:54 +02:00
Jordan Petridis 9f3a5a13b3 EpisodeWidget: Some RefCell are not really necessary. 2018-03-16 20:11:17 +02:00
Jordan Petridis 3a0fb4bdec Upgrade dependancies. 2018-03-14 04:07:44 +02:00
Jordan Petridis 8a460930c6 Update changelog. 2018-03-14 01:10:02 +02:00
Jordan Petridis a11c4c9bd2 InAppNotification: Twek the margins around the text label. 2018-03-14 00:43:17 +02:00
Jordan Petridis d4b98b5cb2 I hate everything that has to do with centering stuff. 2018-03-14 00:40:29 +02:00
Jordan Petridis fdf3908494 This reverts cc052eb450
Turns out debian stable meson package is kinda old.
2018-03-13 07:23:43 +02:00
Jordan Petridis cc052eb450 gitlabci: use meson to test the build instead of cargo. 2018-03-13 07:03:31 +02:00
Jordan Petridis 4535c3005d Merge branch 'state-machines-experiements' into 'master'
EpisodeWidget as a state machine

See merge request alatiera/Hammond!18
2018-03-13 04:52:40 +00:00
Jordan Petridis 030fed6d12 EpisodeWidget: Just in case there was a deadlock. 2018-03-13 04:57:11 +02:00
Jordan Petridis fc9579cd51 EpisodeWidget: Replace some Mutexs with RefCells.
The state machines are not send and the code is sequnecial.
We only need `&mut machine` refference to pass to `take_mut::take`
to change the state of the machine. In 2/3 cases we can even use
`.get_mut()` method and even avoid the dynamic borrow checks at
runtime. For the `TitleMachine` The only thing that will hold
a refference to it after initialization will be the play_button
callback. So it's justifiable to use `RefCell` insetead of a `Mutex`.
2018-03-13 04:44:06 +02:00
Jordan Petridis 74712b5410 EpisodeWidget: Remove unnecessary Arcs.
`DateMachine` and `DurationMachine` are only mutated during initialization
and thus do not need shared ownership.
`TitleMachine` is only mutated during initialization and after that only
the callback will keep holding a referrence to it. The `EpisodeWidget`s
get dropped after initialization. So it's justifiable to use `Rc<Mutex<T>>`
instead of `Arc`.
2018-03-13 03:47:46 +02:00
Jordan Petridis 05628a2529 Update changelog. 2018-03-12 22:32:52 +02:00
Jordan Petridis 1bdd2f2f5b Merge branch 'master' into state-machines-experiements 2018-03-12 22:10:14 +02:00
Jordan Petridis 3af6e103aa Merge branch '49-itunes-to-rss-resolver' into 'master'
Resolve "Itunes to RSS resolver."

Closes #49

See merge request alatiera/Hammond!20
2018-03-12 19:52:51 +00:00
Jordan Petridis 3dcc20ae86 Update changelog. 2018-03-12 21:14:12 +02:00
Jordan Petridis 8a18630ae5 Initial integration of the itunes resolver with the Add button. 2018-03-12 20:49:02 +02:00
Jordan Petridis b87c331b12 Make the itunes_resolver functions inlined. 2018-03-12 07:28:09 +02:00
Jordan Petridis 9da2414e8b Initial implementation of an itunes_to_rss url resolver. #49 2018-03-12 06:48:05 +02:00
Jordan Petridis 285291b2ed Ignore tests that need access to the host system. 2018-03-12 03:35:07 +02:00
Jordan Petridis dbbb4e589e InAppNotification: Fix autohiding after the callback is executed. 2018-03-09 20:24:28 +02:00
Jordan Petridis 064879c4ce InAppNotification: Remove reduntant Overlay. 2018-03-09 19:46:46 +02:00
Jordan Petridis 8614922213 InAppNotification: Change box margins. 2018-03-09 19:36:43 +02:00
Jordan Petridis 745ea0ca10 Flatpak: Add dconf permissions.
Required also in order to run the gtk+ inspecor.
2018-03-09 17:14:49 +02:00
Jordan Petridis 99bc80c15b ShowWidget: Add a 6px margin to the settings popup. 2018-03-09 17:04:11 +02:00
Jordan Petridis 3423d854e1 ShowWidget: Change the mark_all notif wording. 2018-03-09 16:43:13 +02:00
Jordan Petridis 483fd090f1 InAppNotification: Add close button. 2018-03-09 15:25:53 +02:00
Jordan Petridis 82988b6011 Implement in-app notifications as App wide actions.
At first I tried creating custom InAppNotifications and send
them to the main loop to be added. That does not work sicne gtk
widgets are not thread safe. For now we can try having Action messages
that create them on demand. As oppose to create first then pass them.
2018-03-09 14:53:13 +02:00
Jordan Petridis d1821163c2 Factor out the In-app notification into something generic. 2018-03-09 01:21:54 +02:00
Jordan Petridis 7de118adeb Minor style changes. 2018-03-08 23:14:48 +02:00
Jordan Petridis abe7215bc0 Merge branch '47-mark-all-played' into 'master'
Resolve "Mark all episodes of a Show Feed as played."

Closes #47

See merge request alatiera/Hammond!19
2018-03-08 20:57:04 +00:00
Jordan Petridis 7b064e63b9 ShowWidget: Fix undo notif. 2018-03-08 16:21:42 +02:00
Jordan Petridis f6890c709f ShowWidget: Instantly dim episode titles when mark_all is clicked.
This is would have been way prettier, easier and safer if we could
have custom widgets. But till then I am not sure how to do it better.
2018-03-07 23:04:02 +02:00
Jordan Petridis e4814dbfbc ShowWidget: Initial prototype of an undo notification. 2018-03-07 16:37:10 +02:00
Jordan Petridis 8261b32c99 Update changelog. 2018-03-05 22:07:18 +02:00
Jordan Petridis 9a73520b25 dbquerries: Add a unit test for update_none_to_played_now func. 2018-03-05 21:40:11 +02:00
Jordan Petridis 10db4f7210 ShowWidget: Initial implementation of a menu popup.
Re implement mark_all_episodes_as_watched functionality too.
2018-03-05 20:14:06 +02:00
Jordan Petridis 94f6fdcbe7 Clippy. 2018-03-03 18:52:38 +02:00
Jordan Petridis bb467b7aba Rustfmt. 2018-03-03 16:45:37 +02:00
Jordan Petridis 3da503cdea Use prebuilt image for the rustfmt CI check. 2018-02-26 17:49:20 +02:00
Jordan Petridis b062f0a19f EpisodeWidget Machine: Remove unused From impls. 2018-02-22 13:16:33 +00:00
Jordan Petridis 2a6e0b0e07 Merge branch 'master' into state-machines-experiements 2018-02-22 12:14:55 +00:00
Jordan Petridis 1558ee2177 EpisodeWidget: Fix Date states. 2018-02-22 12:01:07 +00:00
Jordan Petridis 3d542e5554 Readme: add dependancy ci banner. 2018-02-21 09:33:03 +00:00
Jordan Petridis c61938ba62 Update dependancies. 2018-02-20 06:19:05 +00:00
Jordan Petridis c856b88008 EpisodeWidget: Add a Date state machine. 2018-02-19 18:14:34 +00:00
Jordan Petridis fce3684113 Readme: Improve flatpak instructions. 2018-02-19 16:52:58 +00:00
Jordan Petridis ae25dd65bf Cargo clippy and fmt. 2018-02-19 09:58:47 +00:00
Jordan Petridis a88a1c5f1f MediaMachine: Expose an interface to update the ProgressBar and local_size. 2018-02-17 19:49:43 +02:00
Jordan Petridis 0cd678cc1d MediaMachine: Expose an interface to update total_size label. 2018-02-16 17:18:02 +02:00
Jordan Petridis c9bf58af66 EpisodeWidget: Expose cancel button from the state machine. 2018-02-16 16:05:48 +02:00
Jordan Petridis ed87a00225 EpisodeWidget: Cleanup parts of the state machine. 2018-02-16 14:43:16 +02:00
Jordan Petridis bcc3608c04 EpisodeWidget: Split ButtonState enum from the MediaMachine.
Add a ButtonState Machine which represents the state of total_size
label, play button, and download button. Also implemented the
update/determine_state function for ButtonState.

Also implemented required generic functions for MediaMachine<X,Y,Z>
that convert it to the desired state.
2018-02-16 13:32:13 +02:00
Jordan Petridis 038d28779c ShowWidget: Limit description to 100 chars width. 2018-02-16 07:45:29 +02:00
Jordan Petridis 973d47ee05 EpisodeWidget: Expose the connect_clicked callbacks from the statemachine enum. 2018-02-15 18:07:21 +02:00
Jordan Petridis e803e11c81 Fix EpisodeWidget Vertical alignment. 2018-02-15 11:56:56 +02:00
Jordan Petridis f50c990d93 Yay, finally something that works. 2018-02-15 11:33:56 +02:00
Jordan Petridis 72eef6f104 Running in circles. 2018-02-15 11:08:21 +02:00
Jordan Petridis 5ccdb5d100 Minor cleanup. 2018-02-15 05:31:36 +02:00
Jordan Petridis 4b8fceaa7d Nothing makes sense. 2018-02-15 05:15:25 +02:00
Jordan Petridis a24c9b1350 EpisodesView: Fix EpisodeWidget spacing. 2018-02-14 12:44:30 +02:00
Jordan Petridis 8913b7aedb SHowWidet: Experiement with dynamic size. Relevant to #35. 2018-02-14 08:44:02 +02:00
Jordan Petridis 978edfc11f EpisodeWidget: Allow the title to ellipsize. Releavnt to #35. 2018-02-14 08:04:59 +02:00
Jordan Petridis f4b41d0fd3 ShowWidget: Restrict horizontal scrolling. Relevant to #35. 2018-02-14 07:56:27 +02:00
Jordan Petridis 20162a16a8 EpisodesView: Re-work box/frame layout
Restrict horizontal scrolling,
Allow the episode widget to expand along when more space becomes available.
2018-02-14 07:03:26 +02:00
Jordan Petridis 73f7bfa64b I dont even know what I am doing at this point. 2018-02-14 04:18:05 +02:00
Jordan Petridis 159b0d92dd EpisodeWidget: Merge Size and Progress machines, Split total_size to it's own machine. 2018-02-13 07:51:00 +02:00
Jordan Petridis 02de2059db EpisodeWidget: Shrink the Size state Machine. 2018-02-13 05:03:16 +02:00
Jordan Petridis bdf8901dd8 This compiles.
Instead of having a Wrapper of StateMachinesWrappers, use only the desired
possible states in A new struct with only 1 Wrapper that covers all 3 of
the embeded state machines.

I don't even know if the comment makes any sense. Sorry.
2018-02-13 02:23:32 +02:00
Jordan Petridis 2fbc833ebe EpisodeWidget: Add a state machine that will manager progress_bar and cancel bttn. 2018-02-10 09:11:31 +02:00
Jordan Petridis f7b5b35374 EpisodeWidget: change DownloadPlayMachine default constructor to a hidden state. 2018-02-10 08:13:07 +02:00
Jordan Petridis 46bd23cf66 EpisodeWidget: Add a StateMachine that manages Play and Download Buttons. 2018-02-10 08:00:12 +02:00
Jordan Petridis 6d9dfe6fe1 EpisodeWidget: Add a StateMachine for the size labels. 2018-02-10 05:41:25 +02:00
Jordan Petridis fc48ce9c47 EpisodeWidget: Migrate Duration Machine to use take mut too, and revert the api to require just &mut self. 2018-02-10 03:33:39 +02:00
Jordan Petridis 3a9a2f4033 EpisdoeWidget: Use take_mut crate to allow for a better api.
Currently it's required that you take mut self in order to manipulate
the internal state machines. This would not allow passing an Arc/Rc to
a callback since A/Rc<T> only derefs to &T and not T.

The take_mut crate allows the retrieval of ownership if you have a &mut refference
and as long you return T again. So Arc<Mutex<Machine> could work with
callbacks and embed Nested state machies without copying.
2018-02-10 03:15:12 +02:00
Jordan Petridis f0ce0eb653 EpisodeWidget: Implement a state machine for duration label. 2018-02-09 10:12:37 +02:00
Jordan Petridis 23979b8f22 EpisodeWidget: Move state machine implementations into a separate module. 2018-02-09 09:13:41 +02:00
Jordan Petridis e22a78fac6 EpisodeWidget: Re-enable on_play_bttn_clicked callback.
Before we were avoiding reloading the widget in view by
directly dimming the title label. Now instead we reload
the whole widget since I can't figure out a way to have
multiple Owneded refferences of the same state machine.
2018-02-09 08:59:50 +02:00
Jordan Petridis 7690cb1356 Remove code duplication using generics. 2018-02-09 08:43:47 +02:00
Jordan Petridis a96f4c57c9 Probably the worst state machine implementation that was ever written. 2018-02-09 08:43:43 +02:00
79 changed files with 5395 additions and 3268 deletions
+1 -5
View File
@@ -3,11 +3,7 @@ target/
Cargo.lock
.vscode
*.ui~
*.gresource
resources.gresource
_build
vendor/
.flatpak-builder/
flatpak-build/
repo/
Makefile
.criterion
+120 -37
View File
@@ -1,61 +1,144 @@
stages:
# meson uses cargo to do the build
# so it's ok to have the tests first.
- test
# - build
- lint
- review
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential
- apt-get install -yqq --no-install-recommends libgtk-3-dev
# - apt-get install -yqq --no-install-recommends meson
variables:
BUNDLE: "hammond-dev.flatpak"
.cargo_cache_template: &cargo_cache
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
.cargo_test_template: &cargo_test
stage: test
script:
- rustc --version && cargo --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test --verbose -- --test-threads=1
- cargo test --verbose -- --test-threads=1 --ignored
variables:
# RUSTFLAGS: "-C link-dead-code"
RUSTFLAGS: "--cfg rayon_unstable"
RUST_BACKTRACE: "FULL"
CARGO_HOME: $CI_PROJECT_DIR/cargo
stable:test:
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson
- mkdir -p .cargo_cache
# Only stuff inside the repo directory can be cached
# Override the CARGO_HOME variable to force it location
- export CARGO_HOME="${PWD}/.cargo_cache"
script:
- rustc -Vv && cargo -Vv
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test -- --test-threads=1
- cargo test -- --test-threads=1 --ignored
<<: *cargo_cache
rust:stable:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
# nightly:test:
# # https://hub.docker.com/r/rustlang/rust/
# image: "rustlang/rust:nightly"
# <<: *cargo_test
rust:nightly:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
only:
- schedule
- web
flatpak:
image: registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.28
stage: test
script:
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/World/hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
# Build the flatpak repo
- flatpak-builder --run app org.gnome.Hammond.json meson --prefix=/app --libdir=/app/lib _build
- flatpak-builder --run app org.gnome.Hammond.json ninja -C _build install
- flatpak-builder --finish-only app org.gnome.Hammond.json
- flatpak build-export repo app
# Create a flatpak bundle
- flatpak build-bundle repo ${BUNDLE} org.gnome.Hammond
# Run the tests
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored
artifacts:
paths:
- $BUNDLE
expire_in: 30 days
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- .flatpak-builder/cache/
- target/
review:
stage: review
dependencies:
- flatpak
script:
- echo "Generating flatpak deployment"
artifacts:
paths:
- $BUNDLE
expire_in: 30 days
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE}
on_stop: stop_review
except:
- master@World/hammond
stop_review:
stage: review
script:
- echo "Stopping flatpak deployment"
when: manual
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
except:
- master@World/hammond
# Configure and run rustfmt on nightly
# Exits and builds fails if on bad format
rustfmt:
image: "rustlang/rust:nightly"
image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:nightly"
stage: lint
variables:
CFG_RELEASE_CHANNEL: "nightly"
script:
- rustc --version && cargo --version
- cargo install rustfmt-nightly --force
- cargo fmt --all -- --write-mode=diff
- rustc -Vv && cargo -Vv
- cargo fmt --version
- cargo fmt --all -- --check
# 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
clippy:
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly"
stage: lint
variables:
RUSTFLAGS: "--cfg rayon_unstable"
CARGO_HOME: ".cargo_cache"
script:
- rustc --version && cargo --version
- cargo clippy --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all
<<: *cargo_cache
+42
View File
@@ -0,0 +1,42 @@
Current problems
<!--
What are the problems that the current project has?
For example:
* User cannot use the keyboard to perform most common actions
or
* User cannot see documents from cloud services
-->
# Goals & use cases
<!--
What are the use cases that this proposal will cover? What are the end goals?
For example:
* User needs to share a file with their friends.
or
* It should be easy to edit a picture within the app.
-->
# Requirements
<!--
What does the solution needs to ensure for being succesful?
For example:
* Work on small form factors and touch
or
* Use the Meson build system and integrate with it
-->
# Relevant art
<!--
Is there any product that has implemented something similar? Put links to other
projects, pictures, links to other code, etc.
-->
# Proposal & plan
<!-- What's the solution and how should be achieved? It can be split in smaller
tasks of minimum change, so they can be delivered across several releases. -->
/label ~"Epic"
+89 -16
View File
@@ -5,27 +5,100 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added:
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown) [!33](https://gitlab.gnome.org/World/hammond/merge_requests/33)
### Changed:
- The `FileChooser` of the OPML import was changed to use the `FileChooserNative` widget/API. (ZanderBrown) [!33](https://gitlab.gnome.org/World/hammond/merge_requests/33)
- The `EpisdeWidget` was refactored. [!38](https://gitlab.gnome.org/World/hammond/merge_requests/38)
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
### Fixed:
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/hammond/merge_requests/37)
### Removed:
## [0.3.4] - 2018-05-20
### Fixed:
- Flatpak can now access the Home folder. This fixes the OPML import feature from
not being able to access any file.
## [0.3.3] - 2018-05-19
### Added:
- Initial functionality for importing shows from an OPML file was implemented.
- ShowsView now rembmers the vertical alignment of the scrollbar between refreshes. 4d2b64e79d8518454b3677612664cd32044cf837
### Changed:
- Minimum `rustc` version requirment was bumped to `1.26`
- Some animations should be smoother now. 7d598bb1d08b05fd5ab532657acdad967c0afbc3
- InAppNotification now can be used to propagate some erros to the user. 7035fe05c4741b3e7ccce6827f72766226d5fc0a and 118dac5a1ab79c0b4ebe78e88256a4a38b138c04
### Fixed:
- Fixed a of by one bug in the `ShowsView` where the last show was never shown. bd12b09cbc8132fd39a266fd091e24bc6c3c040f
## [0.3.2] - 2018-05-07
### Added:
- Vies now have a new fancy scrolling animation when they are refereshed.
### Changed:
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/World/hammond/issues/7)
- Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/World/hammond/issues/25)
- `Rayon` Threadpools are now used instead of unlimited one-off threads.
- `EpisdeWidget`s are now loaded asynchronously accross views.
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
- `ShowWidget` layout was changed 9a5cc1595d982f3232ee7595b83b6512ac8f6c88
- `ShowWidget` Description is inside a scrolled window now
### Fixed:
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/hammond/issues/57)
- Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
### Removed:
- Removed the custom configuration file and replaced instructions to just use meson. 1f1d4af8ba7db8f56435d13a1c191ecff3d4a85b
## [0.3.1] - 2018-03-28
### Added:
- Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/World/hammond/issues/47)
- Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/World/hammond/issues/49)
- Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/World/hammond/issues/50)
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/World/hammond/merge_requests/22) [!23](https://gitlab.gnome.org/World/hammond/merge_requests/23)
- Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/World/hammond/issues/44)
### Changed:
- EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/World/hammond/merge_requests/18)
- Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/World/hammond/issues/35)
- Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/World/hammond/issues/53)
### Fixed:
- Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/World/hammond/issues/52)
## [0.3.0] - 2018-02-11
* 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
+2 -2
View File
@@ -12,7 +12,7 @@ Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOf
## 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)
the web interface [here](https://gitlab.gnome.org/World/hammond)
Development happens in the master branch.
@@ -51,7 +51,7 @@ In order to run the test suite use the following: `cargo test -- --test-threads=
# Issues, issues and more issues!
There are many ways you can contribute to Hammond, and all of them involve creating issues
in [Hammond issue tracker](https://gitlab.gnome.org/alatiera/Hammond/issues). This is the entry point for your contribution.
in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/issues). This is the entry point for your contribution.
To create an effective and high quality ticket, try to put the following information on your
ticket:
Generated
+1016 -665
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,4 +7,3 @@ members = [
[profile.release]
debug = false
View File
+41 -49
View File
@@ -1,12 +1,6 @@
# Hammond
## A Podcast Client for the GNOME Desktop written in Rust.
[![pipeline status](https://gitlab.gnome.org/alatiera/Hammond/badges/master/pipeline.svg)](https://gitlab.gnome.org/alatiera/Hammond/commits/master)
### Features
* TBA
### A Podcast Client for GNOME written in Rust.
![episdes_view](./screenshots/episodes_view.png)
![shows_view](./screenshots/shows_view.png)
@@ -14,36 +8,27 @@
## Quick start
As of January 19 2018, Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) Nightly
and any Stable release of Gnome Builder >= 3.28.0.
Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) >= 3.28.
Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
Manually:
The following steps assume you have a working installation of rustc and cargo.
If you dont take a look at [rustup.rs](https://rustup.rs/)
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
cd Hammond/
cargo run -p hammond-gtk --release
```
## Broken Feeds
Found a feed that does not work in Hammond?
Please [open an issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
## Getting in Touch
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
want to discuss design or simply hang out, please join us in `#hammond` on
[irc.gnome.org.][irc] or [matrix][matrix].
Note:
There isn't much documentation yet, so you will probably have question about parts of the Code.
## Building
### Flatpak
Flatpak is the reccomended way of building and installing Hammond.
@@ -54,55 +39,58 @@ Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
```bash
# Add flathub repo
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak --user remote-add flathub --if-not-exists https://dl.flathub.org/repo/flathub.flatpakrepo
# Add the gnome-nightly repo
flatpak --user remote-add gnome-nightly https://sdk.gnome.org/gnome-nightly.flatpakrepo
flatpak --user remote-add gnome-nightly --if-not-exists https://sdk.gnome.org/gnome-nightly.flatpakrepo
# Install the gnome-nightly Sdk and Platform runtim
flatpak --user install gnome-nightly org.gnome.Sdk org.gnome.Platform
# Install the required rust-stable extension from flathub
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
flatpak-builder --repo=repo hammond org.gnome.Hammond.json --force-clean
flatpak build-bundle repo hammond org.gnome.Hammond
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
```
## Building from soure
To install the resulting flatpak you can do:
```bash
flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
flatpak install --user --bundle hammond.flatpak
```
### Building from soure
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
cd Hammond/
./configure --prefix=/usr/local
make && sudo make install
git clone https://gitlab.gnome.org/World/hammond.git
cd hammond/
meson --prefix=/usr build
ninja -C build
sudo ninja -C build install
```
**Additional:**
#### Dependencies
You can run `sudo make uninstall` for removal
### Dependencies
* Rust stable 1.22 or later.
* Rust stable 1.26 or later along with cargo.
* Gtk+ 3.22 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 libgtk-3-dev meson
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
```
**Fedora**
```sh
dnf install -y gtk3-devel glib2-devel openssl-devel sqlite-devel meson
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
```
If you happen to build it on other distributions please let me know the names of the corresponding libraries. Feel free to open a PR or an Issue to note it.
```sh
git clone https://gitlab.gnome.org/alatiera/Hammond.git
cd Hammond/
cargo build --all
```
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
@@ -110,11 +98,11 @@ There alot of thins 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](https://gitlab.gnome.org/World/hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/World/hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
[contribution-guidelines]: https://gitlab.gnome.org/alatiera/Hammond/blob/master/CONTRIBUTING.md
[contribution-guidelines]: https://gitlab.gnome.org/World/hammond/blob/master/CONTRIBUTING.md
## Overview
@@ -134,7 +122,7 @@ $ tree -d
│   ├── 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.
```
@@ -151,3 +139,7 @@ Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Desi
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
[vendor]: https://github.com/alexcrichton/cargo-vendor
[irc]: irc://irc.gnome.org/#hammond
[matrix]: https://matrix.to/#/#hammond:matrix.org
+2 -9
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.
Vendored
-186
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}
+18 -18
View File
@@ -5,41 +5,41 @@ version = "0.1.0"
workspace = "../"
[dependencies]
ammonia = "1.0.1"
chrono = "0.4.0"
ammonia = "1.1.0"
chrono = "0.4.2"
derive_builder = "0.5.1"
dotenv = "0.10.1"
error-chain = "0.11.0"
itertools = "0.7.6"
lazy_static = "1.0.0"
log = "0.4.1"
rayon = "0.9.0"
rayon = "1.0.1"
rayon-futures = "0.1.0"
rfc822_sanitizer = "0.3.3"
rss = "1.2.1"
url = "1.6.0"
rss = "1.5.0"
url = "1.7.0"
xdg = "2.1.0"
futures = "0.1.18"
hyper = "0.11.18"
tokio-core = "0.1.12"
hyper-tls = "0.1.2"
xml-rs = "0.8.0"
futures = "0.1.21"
hyper = "0.11.27"
tokio-core = "0.1.17"
hyper-tls = "0.1.3"
native-tls = "0.1.5"
futures-cpupool = "0.1.8"
num_cpus = "1.8.0"
failure = "0.1.1"
failure_derive = "0.1.1"
[dependencies.diesel]
features = ["sqlite", "r2d2"]
version = "1.1.1"
version = "= 1.2.2"
[dependencies.diesel_migrations]
features = ["sqlite"]
version = "1.1.0"
version = "= 1.2.0"
[dev-dependencies]
rand = "0.4.2"
tempdir = "0.3.6"
criterion = "0.2.0"
rand = "0.5.0"
tempdir = "0.3.7"
criterion = "0.2.3"
pretty_assertions = "0.5.1"
maplit = "1.0.1"
[[bench]]
name = "bench"
+5 -26
View File
@@ -1,9 +1,10 @@
#![allow(unused)]
#[macro_use]
extern crate criterion;
use criterion::Criterion;
// extern crate futures;
// extern crate futures_cpupool;
extern crate hammond_data;
extern crate hyper;
extern crate hyper_tls;
@@ -15,13 +16,12 @@ extern crate rss;
// use rayon::prelude::*;
// use futures::future::*;
// use futures_cpupool::CpuPool;
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::FeedBuilder;
use hammond_data::Source;
// use hammond_data::errors::*;
use std::io::BufReader;
@@ -58,22 +58,6 @@ static FEEDS: &[(&[u8], &str)] = &[
(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";
@@ -116,10 +100,5 @@ fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap();
}
criterion_group!(
benches,
bench_pipeline,
bench_index_large_feed,
bench_index_small_feed
);
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed);
criterion_main!(benches);
+3 -4
View File
@@ -1,4 +1,6 @@
//! 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;
@@ -30,10 +32,7 @@ extern crate tempdir;
#[cfg(test)]
lazy_static! {
static ref TEMPDIR: tempdir::TempDir = {
tempdir::TempDir::new("hammond_unit_test").unwrap()
};
static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("hammond_unit_test").unwrap() };
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
}
+131 -17
View File
@@ -5,6 +5,7 @@ use diesel::prelude::*;
use diesel;
use diesel::dsl::exists;
use diesel::query_builder::AsQuery;
use diesel::select;
use database::connection;
@@ -33,6 +34,18 @@ pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
.map_err(From::from)
}
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.order(title.asc())
.filter(id.ne_all(filter_ids))
.load::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -90,6 +103,20 @@ pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
.map_err(From::from)
}
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetQuery, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
))
.filter(rowid.eq(ep_id))
.get_result::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -102,24 +129,33 @@ pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataE
.map_err(From::from)
}
pub fn get_episodes_widgets_with_limit(limit: u32) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode;
pub fn get_episode_description(rowid_: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*;
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())
episode
.filter(rowid.eq(rowid_))
.select(description)
.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::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
);
episode
.select(columns)
.order(epoch.desc())
.filter(podcast_id.ne_all(filter_ids))
.limit(i64::from(limit))
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
@@ -159,14 +195,27 @@ pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
.map_err(From::from)
}
pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
let db = connection();
let con = db.get()?;
Episode::belonging_to(parent)
.count()
.get_result(&con)
.map_err(From::from)
}
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
);
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
episode
.select(columns)
.filter(podcast_id.eq(parent.id()))
// .group_by(epoch)
.order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
@@ -323,6 +372,45 @@ pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, Dat
.map_err(From::from)
}
pub fn episode_had_description(rowid_: i32) -> Result<bool, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.filter(rowid.eq(rowid_))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `episode` table contains any rows
///
/// Return true if `episode` table is populated.
pub fn is_episodes_populated() -> Result<bool, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.as_query()))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `podcast` table contains any rows
///
/// Return true if `podcast table is populated.
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(podcast.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -348,3 +436,29 @@ pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
.map_err(From::from)
})
}
#[cfg(test)]
mod tests {
use super::*;
use database::*;
use pipeline;
#[test]
fn test_update_none_to_played_now() {
truncate_db().unwrap();
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url).unwrap();
let id = source.id();
pipeline::run(vec![source], true).unwrap();
let pd = get_podcast_from_source_id(id).unwrap();
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_ne!(eps_num, 0);
update_none_to_played_now(&pd).unwrap();
let eps_num2 = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_eq!(eps_num2, 0);
}
}
+40 -16
View File
@@ -3,16 +3,31 @@ use diesel::r2d2;
use diesel_migrations::RunMigrationsError;
use hyper;
use native_tls;
// use rss;
use rss;
use url;
use xml;
use std::io;
// use std::fmt;
// fadsadfs NOT SYNC
// #[derive(Fail, Debug)]
// #[fail(display = "RSS Error: {}", _0)]
// struct RSSError(rss::Error);
use models::Source;
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
#[derive(Fail, Debug)]
pub struct HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
}
impl HttpStatusError {
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
HttpStatusError {
url,
status_code: code,
context,
}
}
}
#[derive(Fail, Debug)]
pub enum DataError {
@@ -34,20 +49,17 @@ pub enum DataError {
#[fail(display = "IO Error: {}", _0)]
IOError(#[cause] io::Error),
#[fail(display = "RSS Error: {}", _0)]
// Rss::Error is not yet Sync
RssCrateError(String),
RssError(#[cause] rss::Error),
#[fail(display = "XML Reader Error: {}", _0)]
XmlReaderError(#[cause] xml::reader::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 = "{}", _0)]
HttpStatusGeneral(HttpStatusError),
#[fail(display = "FIXME: This should be better")]
F301(Source),
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
ParseEpisodeError { reason: String, parent_id: i32 },
#[fail(display = "No Futures where produced to be run.")]
EmptyFuturesList,
#[fail(display = "Episode was not changed and thus skipped.")]
EpisodeNotChanged,
}
@@ -100,6 +112,18 @@ impl From<io::Error> for DataError {
}
}
impl From<rss::Error> for DataError {
fn from(err: rss::Error) -> Self {
DataError::RssError(err)
}
}
impl From<xml::reader::Error> for DataError {
fn from(err: xml::reader::Error) -> Self {
DataError::XmlReaderError(err)
}
}
impl From<String> for DataError {
fn from(err: String) -> Self {
DataError::Bail(err)
+90 -99
View File
@@ -1,16 +1,15 @@
#![cfg_attr(feature = "cargo-clippy", allow(unit_arg))]
//! Index Feeds.
use futures::future::*;
use itertools::{Either, Itertools};
use futures::prelude::*;
use futures::stream;
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)>>);
use models::{NewEpisode, NewEpisodeMinimal, NewPodcast, Podcast};
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field.
@@ -26,90 +25,104 @@ pub struct Feed {
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()
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send {
self.parse_podcast_async()
.and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(&pd));
Box::new(fut)
.and_then(move |pd| self.index_channel_items(pd))
}
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 parse_podcast_async(&self) -> impl Future<Item = NewPodcast, Error = DataError> + Send {
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);
};
})
}
fn index_channel_items(self, pd: Podcast) -> impl Future<Item = (), Error = DataError> + Send {
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
// Parse the episodes
let episodes = stream.filter_map(move |item| {
glue(&item, pd.id())
.map_err(|err| error!("Failed to parse an episode: {}", err))
.ok()
});
Box::new(fut)
// Filter errors, Index updatable episodes, return insertables.
filter_episodes(episodes)
// Batch index insertable episodes.
.and_then(|eps| ok(batch_insert_episodes(&eps)))
}
}
fn get_stuff(
&self,
pd: &Podcast,
) -> Box<Future<Item = InsertUpdate, Error = DataError> + Send> {
let (insert, update): (Vec<_>, Vec<_>) = self.channel
.items()
.into_iter()
.map(|item| glue_async(item, pd.id()))
// This is sort of ugly but I think it's cheaper than pushing None
// to updated and filtering it out later.
// Even though we already map_filter in index_channel_items.
// I am not sure what the optimizations are on match vs allocating None.
.map(|fut| {
fut.and_then(|x| match x {
IndexState::NotChanged => return Err(DataError::EpisodeNotChanged),
_ => Ok(x),
fn glue(item: &rss::Item, id: i32) -> Result<IndexState<NewEpisode>, DataError> {
NewEpisodeMinimal::new(item, id).and_then(move |ep| determine_ep_state(ep, item))
}
fn determine_ep_state(
ep: NewEpisodeMinimal,
item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
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)
}
}
}
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.");
})
.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),
.unwrap_or_else(|_| {
episodes.iter().for_each(|ep| {
ep.index()
.map_err(|err| error!("Error: {}.", err))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.ok();
});
Box::new(ok((insert, update)))
}
})
}
#[cfg(test)]
@@ -117,10 +130,10 @@ mod tests {
use rss::Channel;
use tokio_core::reactor::Core;
use Source;
use database::truncate_db;
use dbqueries;
use utils::get_feed;
use Source;
use std::fs;
use std::io::BufReader;
@@ -161,7 +174,8 @@ mod tests {
fn test_complete_index() {
truncate_db().unwrap();
let feeds: Vec<_> = URLS.iter()
let feeds: Vec<_> = URLS
.iter()
.map(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
@@ -202,31 +216,8 @@ mod tests {
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
feed.index_channel_items(&pd).wait().unwrap();
feed.index_channel_items(pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
}
#[test]
fn test_feed_get_stuff() {
truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(43, insert.len());
assert_eq!(0, update.len());
feed.index().wait().unwrap();
let path = "tests/feeds/2018-02-03-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(4, insert.len());
assert_eq!(43, update.len());
}
}
+40 -24
View File
@@ -1,25 +1,36 @@
#![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)]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
#![cfg_attr(feature = "cargo-clippy", allow(option_map_unit_fn))]
#![cfg_attr(
feature = "clippy",
warn(
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut,
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else,
items_after_statements, used_underscore_binding
)
)]
#![warn(
bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals, path_statements,
patterns_in_fns_without_body, plugin_as_library, private_in_public, private_no_mangle_fns,
private_no_mangle_statics, safe_extern_statics, unconditional_recursion,
unions_with_drop_fields, unused_allocation, unused_comparisons, unused_parens, while_true,
missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts,
unused_extern_crates, unused
)]
#![deny(warnings)]
//! FIXME: Docs
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
#[cfg(test)]
#[macro_use]
extern crate maplit;
#[macro_use]
extern crate derive_builder;
#[macro_use]
@@ -38,34 +49,39 @@ extern crate log;
extern crate ammonia;
extern crate chrono;
extern crate futures;
extern crate futures_cpupool;
extern crate hyper;
extern crate hyper_tls;
extern crate itertools;
extern crate native_tls;
extern crate num_cpus;
extern crate rayon;
extern crate rayon_futures;
extern crate rfc822_sanitizer;
extern crate rss;
extern crate tokio_core;
extern crate url;
extern crate xdg;
extern crate xml;
pub mod database;
#[allow(missing_docs)]
pub mod dbqueries;
#[allow(missing_docs)]
pub mod errors;
pub mod utils;
pub mod database;
pub mod pipeline;
pub(crate) mod models;
mod feed;
pub(crate) mod models;
pub mod opml;
mod parser;
pub mod pipeline;
mod schema;
pub mod utils;
pub use feed::{Feed, FeedBuilder};
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
pub use models::Save;
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
// Set the user agent, See #53 for more
// Keep this in sync with Tor-browser releases
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0";
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]
+19 -10
View File
@@ -1,7 +1,7 @@
use chrono::prelude::*;
use diesel;
use diesel::SaveChangesDsl;
use diesel::prelude::*;
use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
@@ -31,9 +31,12 @@ pub struct Episode {
podcast_id: i32,
}
impl Save<Episode, DataError> for Episode {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<Episode, DataError> {
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, Self::Error> {
let db = connection();
let tempdb = db.get()?;
@@ -223,9 +226,12 @@ impl From<Episode> for EpisodeWidgetQuery {
}
}
impl Save<usize, DataError> for EpisodeWidgetQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<usize, DataError> {
impl Save<usize> for EpisodeWidgetQuery {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, Self::Error> {
use schema::episode::dsl::*;
let db = connection();
@@ -361,9 +367,12 @@ pub struct EpisodeCleanerQuery {
played: Option<i32>,
}
impl Save<usize, DataError> for EpisodeCleanerQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<usize, DataError> {
impl Save<usize> for EpisodeCleanerQuery {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, Self::Error> {
use schema::episode::dsl::*;
let db = connection();
+18 -9
View File
@@ -30,21 +30,30 @@ pub enum IndexState<T> {
NotChanged,
}
pub trait Insert<T, E> {
fn insert(&self) -> Result<T, E>;
pub trait Insert<T> {
type Error;
fn insert(&self) -> Result<T, Self::Error>;
}
pub trait Update<T, E> {
fn update(&self, i32) -> Result<T, E>;
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, E>: Insert<T, E> + Update<T, E> {
fn index(&self) -> Result<T, E>;
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, E> {
/// Helper method to easily save/"sync" current state of a diesel model to the Database.
fn save(&self) -> Result<T, E>;
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>;
}
+73 -62
View File
@@ -10,7 +10,7 @@ use errors::DataError;
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
use parser;
use schema::episode;
use utils::{replace_extra_spaces, url_cleaner};
use utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
@@ -43,7 +43,9 @@ impl From<NewEpisodeMinimal> for NewEpisode {
}
}
impl Insert<(), DataError> for NewEpisode {
impl Insert<()> for NewEpisode {
type Error = DataError;
fn insert(&self) -> Result<(), DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -58,7 +60,9 @@ impl Insert<(), DataError> for NewEpisode {
}
}
impl Update<(), DataError> for NewEpisode {
impl Update<()> for NewEpisode {
type Error = DataError;
fn update(&self, episode_id: i32) -> Result<(), DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -73,8 +77,11 @@ impl Update<(), DataError> for NewEpisode {
}
}
impl Index<(), DataError> for NewEpisode {
// Does not update the episode description if it's the only thing that has changed.
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())?;
@@ -94,17 +101,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.podcast_id() == other.podcast_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.podcast_id() == other.podcast_id())
&& (self.description() == other.description())
&& (self.length() == other.length())
}
@@ -175,9 +188,12 @@ pub(crate) struct NewEpisodeMinimal {
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.podcast_id() == other.podcast_id())
}
}
@@ -185,7 +201,7 @@ impl NewEpisodeMinimal {
pub(crate) fn new(item: &rss::Item, parent_id: i32) -> Result<Self, DataError> {
if item.title().is_none() {
let err = DataError::ParseEpisodeError {
reason: format!("No title specified for this Episode."),
reason: "No title specified for this Episode.".into(),
parent_id,
};
@@ -195,13 +211,16 @@ impl NewEpisodeMinimal {
let title = item.title().unwrap().trim().to_owned();
let guid = item.guid().map(|s| s.value().trim().to_owned());
let uri = if let Some(url) = item.enclosure().map(|s| url_cleaner(s.url())) {
Some(url)
} else if item.link().is_some() {
item.link().map(|s| url_cleaner(s))
} else {
let uri = item.enclosure()
.map(|s| url_cleaner(s.url().trim()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s.trim())));
// If url is still None return an Error as this behaviour is
// compliant with the RSS Spec.
if uri.is_none() {
let err = DataError::ParseEpisodeError {
reason: format!("No url specified for the item."),
reason: "No url specified for the item.".into(),
parent_id,
};
@@ -211,7 +230,7 @@ impl NewEpisodeMinimal {
// Default to rfc2822 represantation 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.
// Case: "Thu, 05 Aug 2016 06:00:00 -0400" <-- Actually that was friday.
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
let duration = parser::parse_itunes_duration(item.itunes_ext());
@@ -227,18 +246,17 @@ impl NewEpisodeMinimal {
.map_err(From::from)
}
// TODO: TryInto is stabilizing in rustc v1.26!
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
let length = || -> Option<i32> { item.enclosure().map(|x| x.length().parse().ok())? }();
// Prefer itunes summary over rss.description since many feeds put html into
// rss.description.
let summary = item.itunes_ext().map(|s| s.summary()).and_then(|s| s);
let description = if summary.is_some() {
summary.map(|s| replace_extra_spaces(&ammonia::clean(s)))
} else {
item.description()
.map(|s| replace_extra_spaces(&ammonia::clean(s)))
};
let length = item.enclosure().and_then(|x| x.length().parse().ok());
let description = item.description().and_then(|s| {
let sanitized_html = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(s.trim())
.to_string();
Some(sanitized_html)
});
NewEpisodeBuilder::default()
.title(self.title)
@@ -284,8 +302,8 @@ impl NewEpisodeMinimal {
mod tests {
use database::truncate_db;
use dbqueries;
use models::*;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use models::*;
use rss::Channel;
@@ -310,7 +328,6 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_INTERCEPTED_2: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
@@ -324,13 +341,12 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_INTERCEPTED_1: NewEpisode = {
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data breach \
and allegations of Russian interference in the US election. Commentator \
Shaun King explains his call for a boycott of the NFL and talks about his \
campaign to bring violent neo-Nazis to justice. Rapper Open Mike Eagle \
performs.";
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data \
breach and allegations of Russian interference in the US election. \
Commentator Shaun King explains his call for a boycott of the NFL and \
talks about his campaign to bring violent neo-Nazis to justice. Rapper \
Open Mike Eagle performs.";
NewEpisodeBuilder::default()
.title("The Super Bowl of Racism")
@@ -346,16 +362,15 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_INTERCEPTED_2: NewEpisode = {
let descr = "This week on Intercepted: Jeremy gives an update on the aftermath of \
Blackwater’s 2007 massacre of Iraqi civilians. Intercept reporter Lee Fang \
lays out how a network of libertarian think tanks called the Atlas Network \
is insidiously shaping political infrastructure in Latin America. We speak \
with attorney and former Hugo Chavez adviser Eva Golinger about the \
Venezuela\'s political turmoil.And we hear Claudia Lizardo of the \
Caracas-based band, La Pequeña Revancha, talk about her music and hopes for \
Venezuela.";
Blackwater’s 2007 massacre of Iraqi civilians. Intercept reporter Lee \
Fang lays out how a network of libertarian think tanks called the Atlas \
Network is insidiously shaping political infrastructure in Latin \
America. We speak with attorney and former Hugo Chavez adviser Eva \
Golinger about the Venezuela\'s political turmoil.And we hear Claudia \
Lizardo of the Caracas-based band, La Pequeña Revancha, talk about her \
music and hopes for Venezuela.";
NewEpisodeBuilder::default()
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
@@ -371,7 +386,6 @@ mod tests {
.build()
.unwrap()
};
static ref UPDATED_DURATION_INTERCEPTED_1: NewEpisode = {
NewEpisodeBuilder::default()
.title("The Super Bowl of Racism")
@@ -387,7 +401,6 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_1: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Hacking Devices with Kali Linux | LUP 214")
@@ -401,7 +414,6 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_2: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Gnome Does it Again | LUP 213")
@@ -415,12 +427,11 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_LUP_1: NewEpisode = {
let descr = "Audit your network with a couple of easy commands on Kali Linux. Chris \
decides to blow off a little steam by attacking his IoT devices, Wes has the \
scope on Equifax blaming open source &amp; the Beard just saved the show. \
It’s a really packed episode!";
decides to blow off a little steam by attacking his IoT devices, Wes has \
the scope on Equifax blaming open source &amp; the Beard just saved the \
show. It’s a really packed episode!";
NewEpisodeBuilder::default()
.title("Hacking Devices with Kali Linux | LUP 214")
@@ -436,13 +447,13 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_LUP_2: NewEpisode = {
let descr = "The Gnome project is about to solve one of our audience's biggest Wayland’s \
concerns. But as the project takes on a new level of relevance, decisions for the \
next version of Gnome have us worried about the future.\nPlus we chat with Wimpy \
about the Ubuntu Rally in NYC, Microsoft’s sneaky move to turn Windows 10 into the \
“ULTIMATE LINUX RUNTIME”, community news &amp; more!";
let descr =
"<p>The Gnome project is about to solve one of our audience's biggest Wayland’s \
concerns. But as the project takes on a new level of relevance, decisions for \
the next version of Gnome have us worried about the future.</p>\n\n<p>Plus we \
chat with Wimpy about the Ubuntu Rally in NYC, Microsoft’s sneaky move to turn \
Windows 10 into the “ULTIMATE LINUX RUNTIME”, community news &amp; more!</p>";
NewEpisodeBuilder::default()
.title("Gnome Does it Again | LUP 213")
+75 -56
View File
@@ -1,17 +1,16 @@
use ammonia;
use diesel;
use diesel::prelude::*;
use ammonia;
use rss;
use errors::DataError;
use models::{Index, Insert, Update};
use models::Podcast;
use models::{Index, Insert, Update};
use schema::podcast;
use database::connection;
use dbqueries;
use utils::{replace_extra_spaces, url_cleaner};
use utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "podcast"]
@@ -27,8 +26,10 @@ pub(crate) struct NewPodcast {
source_id: i32,
}
impl Insert<(), DataError> for NewPodcast {
fn insert(&self) -> Result<(), DataError> {
impl Insert<()> for NewPodcast {
type Error = DataError;
fn insert(&self) -> Result<(), Self::Error> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
@@ -41,8 +42,10 @@ impl Insert<(), DataError> for NewPodcast {
}
}
impl Update<(), DataError> for NewPodcast {
fn update(&self, podcast_id: i32) -> Result<(), DataError> {
impl Update<()> for NewPodcast {
type Error = DataError;
fn update(&self, podcast_id: i32) -> Result<(), Self::Error> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
@@ -58,7 +61,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 NewPodcast {
type Error = DataError;
fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::podcast_exists(self.source_id)?;
@@ -78,7 +83,8 @@ impl Index<(), DataError> for NewPodcast {
impl PartialEq<Podcast> for NewPodcast {
fn eq(&self, other: &Podcast) -> bool {
(self.link() == other.link()) && (self.title() == other.title())
(self.link() == other.link())
&& (self.title() == other.title())
&& (self.image_uri() == other.image_uri())
&& (self.description() == other.description())
&& (self.source_id() == other.source_id())
@@ -89,23 +95,21 @@ impl NewPodcast {
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
let title = chan.title().trim();
let link = url_cleaner(chan.link().trim());
// Prefer itunes summary over rss.description since many feeds put html into
// rss.description.
let summary = chan.itunes_ext().map(|s| s.summary()).and_then(|s| s);
let description = if let Some(sum) = summary {
replace_extra_spaces(&ammonia::clean(sum))
} else {
replace_extra_spaces(&ammonia::clean(chan.description()))
};
let description = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(chan.description().trim())
.to_string();
let link = url_cleaner(chan.link());
let x = chan.itunes_ext().map(|s| s.image());
let image_uri = if let Some(img) = x {
img.map(|s| s.to_owned())
} else {
chan.image().map(|foo| foo.url().to_owned())
};
// Try to get the itunes img first
let itunes_img = chan
.itunes_ext()
.and_then(|s| s.image().map(|url| url.trim()))
.map(|s| s.to_owned());
// If itunes is None, try to get the channel.image from the rss spec
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned()));
NewPodcastBuilder::default()
.title(title)
@@ -165,10 +169,11 @@ mod tests {
lazy_static! {
static ref EXPECTED_INTERCEPTED: NewPodcast = {
let descr = "The people behind The Intercept’s fearless reporting and incisive \
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss \
the crucial issues of our time: national security, civil liberties, foreign \
policy, and criminal justice. Plus interviews with artists, thinkers, and \
newsmakers who challenge our preconceptions about the world we live in.";
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \
others—discuss the crucial issues of our time: national security, civil \
liberties, foreign policy, and criminal justice. Plus interviews with \
artists, thinkers, and newsmakers who challenge our preconceptions about \
the world we live in.";
NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill")
@@ -183,11 +188,10 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_LUP: NewPodcast = {
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.";
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()
.title("LINUX Unplugged Podcast")
@@ -200,17 +204,16 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_TIPOFF: NewPodcast = {
let desc = "Welcome to The Tip Off- the podcast where we take you behind the scenes of \
some of the best investigative journalism from recent years. Each episode \
we’ll be digging into an investigative scoop- hearing from the journalists \
behind the work as they tell us about the leads, the dead-ends and of course, \
the tip offs. There’ll be car chases, slammed doors, terrorist cells, \
meetings in dimly lit bars and cafes, wrangling with despotic regimes and \
much more. So if you’re curious about the fun, complicated detective work \
that goes into doing great investigative journalism- then this is the podcast \
for you.";
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
scenes of some of the best investigative journalism from recent years. \
Each episode we’ll be digging into an investigative scoop- hearing from \
the journalists behind the work as they tell us about the leads, the \
dead-ends and of course, the tip offs. There’ll be car chases, slammed \
doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling \
with despotic regimes and much more. So if you’re curious about the fun, \
complicated detective work that goes into doing great investigative \
journalism- then this is the podcast for you.</p>";
NewPodcastBuilder::default()
.title("The Tip Off")
@@ -223,15 +226,13 @@ mod tests {
.source_id(42)
.build()
.unwrap()
};
static ref EXPECTED_STARS: NewPodcast = {
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 ever \
attempted - scripted by Mac Rogers, the award-winning playwright and writer \
of the multi-million download The Message and LifeAfter.</p>";
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 \
ever attempted - scripted by Mac Rogers, the award-winning playwright \
and writer of the multi-million download The Message and LifeAfter.</p>";
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
@@ -245,11 +246,10 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_CODE: NewPodcast = {
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.";
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()
.title("Greater Than Code")
@@ -262,7 +262,16 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
NewPodcastBuilder::default()
.title("Ελληνοφρένεια")
.link("https://ellinofreneia.sealabs.net/feed.rss")
.description("Ανεπίσημο feed της Ελληνοφρένειας")
.image_uri(Some("https://ellinofreneia.sealabs.net/logo.png".into()))
.source_id(42)
.build()
.unwrap()
};
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill")
@@ -324,6 +333,15 @@ mod tests {
assert_eq!(*EXPECTED_CODE, pd);
}
#[test]
fn test_new_podcast_ellinofreneia() {
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
}
#[test]
// This maybe could be a doc test on insert.
fn test_new_podcast_insert() {
@@ -343,7 +361,8 @@ mod tests {
#[test]
// TODO: Add more test/checks
// Currently there's a test that only checks new description or title.
// If you have time and want to help, implement the test for the other fields too.
// If you have time and want to help, implement the test for the other fields
// too.
fn test_new_podcast_update() {
truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
-2
View File
@@ -1,5 +1,3 @@
#![allow(unused_mut)]
use diesel;
use diesel::prelude::*;
use url::Url;
+6 -3
View File
@@ -25,9 +25,12 @@ pub struct Podcast {
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> {
impl Save<Podcast> for Podcast {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Podcast, Self::Error> {
let db = connection();
let tempdb = db.get()?;
+64 -98
View File
@@ -3,21 +3,23 @@ use diesel::SaveChangesDsl;
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};
use hyper::header::{
ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified, Location, UserAgent,
};
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper_tls::HttpsConnector;
// use futures::future::ok;
use futures::future::{loop_fn, Future, Loop};
use futures::prelude::*;
use futures_cpupool::CpuPool;
use database::connection;
use errors::DataError;
use errors::*;
use feed::{Feed, FeedBuilder};
use models::{NewSource, Save};
use schema::source;
use USER_AGENT;
use std::str::FromStr;
@@ -33,9 +35,12 @@ pub struct Source {
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> {
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()?;
@@ -101,6 +106,10 @@ impl Source {
Ok(())
}
fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
}
// TODO match on more stuff
// 301: Moved Permanently
// 304: Up to date Feed, checked with the Etag
@@ -111,73 +120,27 @@ impl Source {
// 408: Timeout
// 410: Feed deleted
// TODO: Rething this api,
fn match_status(mut self, res: Response) -> Result<(Self, Response), DataError> {
fn match_status(mut self, res: Response) -> Result<Response, DataError> {
self.update_etag(&res)?;
let code = res.status();
match code {
StatusCode::NotModified => {
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("304: skipping.."),
};
return Err(err);
}
match code {
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
StatusCode::MovedPermanently => {
error!("Feed was moved permanently.");
self.handle_301(&res)?;
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("301: Feed was moved permanently."),
};
return Err(err);
return Err(DataError::F301(self));
}
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
StatusCode::Unauthorized => {
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("401: Unauthorized."),
};
return Err(err);
}
StatusCode::Forbidden => {
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("403: Forbidden."),
};
return Err(err);
}
StatusCode::NotFound => return Err(format!("404: Not found.")).map_err(From::from),
StatusCode::RequestTimeout => {
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("408: Request Timeout."),
};
return Err(err);
}
StatusCode::Gone => {
let err = DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: format!("410: Feed was deleted.."),
};
return Err(err);
}
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))
Ok(res)
}
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
@@ -189,8 +152,6 @@ impl Source {
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(())
@@ -215,22 +176,34 @@ impl Source {
// Refactor into TryInto once it lands on stable.
pub fn into_feed(
self,
client: &Client<HttpsConnector<HttpConnector>>,
pool: CpuPool,
client: Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = Feed, Error = DataError>> {
) -> impl 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, pool))
let response = loop_fn(self, move |source| {
source
.request_constructor(&client.clone(), ignore_etags)
.then(|res| match res {
Ok(response) => Ok(Loop::Break(response)),
Err(err) => match err {
DataError::F301(s) => {
info!("Following redirect...");
Ok(Loop::Continue(s))
}
e => Err(e),
},
})
});
response
.and_then(response_to_channel)
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.map_err(From::from)
});
Box::new(feed)
})
}
// TODO: make ignore_etags an Enum for better ergonomics.
@@ -239,49 +212,43 @@ impl Source {
self,
client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = (Self, Response), Error = DataError>> {
) -> impl Future<Item = Response, Error = DataError> {
// FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::new(Method::Get, uri);
// Set the UserAgent cause ppl still seem to check it for some reason...
req.headers_mut().set(UserAgent::new(USER_AGENT));
if !ignore_etags {
if let Some(foo) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(vec![
EntityTag::new(true, foo.to_owned()),
]));
if let Some(etag) = self.http_etag() {
let tag = vec![EntityTag::new(true, etag.to_owned())];
req.headers_mut().set(IfNoneMatch::Items(tag));
}
if let Some(foo) = self.last_modified() {
if let Ok(x) = foo.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(x));
if let Some(lmod) = self.last_modified() {
if let Ok(date) = lmod.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(date));
}
}
}
let work = client
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)
.and_then(move |res| self.match_status(res))
}
}
fn response_to_channel(
res: Response,
pool: CpuPool,
) -> Box<Future<Item = Channel, Error = DataError> + Send> {
let chan = res.body()
#[allow(needless_pass_by_value)]
fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = DataError> + Send {
res.body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| {
Channel::from_str(&buf).or_else(|err| Err(DataError::RssCrateError(format!("{}", err))))
});
let cpu_chan = pool.spawn(chan);
Box::new(cpu_chan)
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
}
#[cfg(test)]
@@ -296,7 +263,6 @@ mod tests {
fn test_into_feed() {
truncate_db().unwrap();
let pool = CpuPool::new_num_cpus();
let mut core = Core::new().unwrap();
let client = Client::configure()
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
@@ -307,7 +273,7 @@ mod tests {
let source = Source::from_url(url).unwrap();
let id = source.id();
let feed = source.into_feed(&client, pool.clone(), true);
let feed = source.into_feed(client, true);
let feed = core.run(feed).unwrap();
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
+167
View File
@@ -0,0 +1,167 @@
//! FIXME: Docs
// #![allow(unused)]
use errors::DataError;
use models::Source;
use xml::reader;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::Path;
// use std::fs::{File, OpenOptions};
// use std::io::BufReader;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
// FIXME: Make it a Diesel model
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
/// not `RSS` related sub-elements are ommited.
///
/// [spec]: http://dev.opml.org/spec2.html
pub struct Opml {
title: String,
description: String,
url: String,
}
/// Import feed url's from a `R` into the `Source` table.
// TODO: Write test
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
let feeds = extract_sources(reader)?
.iter()
.map(|opml| Source::from_url(&opml.url))
.filter_map(|s| {
if let Err(ref err) = s {
let txt = "If you think this might be a bug please consider filling a report over \
at https://gitlab.gnome.org/World/hammond/issues/new";
error!("Failed to import a Show: {}", err);
error!("{}", txt);
}
s.ok()
})
.collect();
Ok(feeds)
}
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
/// return the new `Source`s
// TODO: Write test
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
let content = fs::read(path)?;
import_to_db(content.as_slice()).map_err(From::from)
}
/// Extracts the `outline` elemnts from a reader `R` and returns a `HashSet` of `Opml` structs.
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
let mut list = HashSet::new();
let parser = reader::EventReader::new(reader);
parser
.into_iter()
.map(|e| match e {
Ok(reader::XmlEvent::StartElement {
name, attributes, ..
}) => {
if name.local_name == "outline" {
let mut title = String::new();
let mut url = String::new();
let mut description = String::new();
attributes.into_iter().for_each(|attribute| {
match attribute.name.local_name.as_str() {
"title" => title = attribute.value,
"xmlUrl" => url = attribute.value,
"description" => description = attribute.value,
_ => {}
}
});
let feed = Opml {
title,
description,
url,
};
list.insert(feed);
}
Ok(())
}
Err(err) => Err(err),
_ => Ok(()),
})
.collect::<Result<Vec<_>, reader::Error>>()?;
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
#[test]
fn test_extract() {
let int_title = String::from("Intercepted with Jeremy Scahill");
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
let int_desc =
String::from(
"The people behind The Intercept’s fearless reporting and incisive \
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss the \
crucial issues of our time: national security, civil liberties, foreign policy, \
and criminal justice. Plus interviews with artists, thinkers, and newsmakers \
who challenge our preconceptions about the world we live in.",
);
let dec_title = String::from("Deconstructed with Mehdi Hasan");
let dec_url = String::from("https://rss.prod.firstlook.media/deconstructed/podcast.rss");
let dec_desc = String::from(
"Journalist Mehdi Hasan is known around the world for his televised takedowns of \
presidents and prime ministers. In this new podcast from The Intercept, Mehdi \
unpacks a game-changing news event of the week while challenging the conventional \
wisdom. As a Brit, a Muslim and an immigrant based in Donald Trump's Washington \
D.C., Mehdi gives a refreshingly provocative perspective on the ups and downs of \
American—and global—politics.",
);
#[cfg_attr(rustfmt, rustfmt_skip)]
let sample1 = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
<opml version=\"2.0\"> \
<head> \
<title>Test OPML File</title> \
<dateCreated>{}</dateCreated> \
<docs>http://www.opml.org/spec2</docs> \
</head> \
<body> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
</body> \
</opml>",
Local::now().format("%a, %d %b %Y %T %Z"),
int_title,
int_desc,
int_url,
dec_title,
dec_desc,
dec_url,
);
let map = hashset![
Opml {
title: int_title,
description: int_desc,
url: int_url
},
Opml {
title: dec_title,
description: dec_desc,
url: dec_url
},
];
assert_eq!(extract_sources(sample1.as_bytes()).unwrap(), map);
}
}
+54 -143
View File
@@ -2,24 +2,25 @@
//! Docs.
use futures::future::*;
use futures_cpupool::CpuPool;
// use futures::prelude::*;
use futures::prelude::*;
use futures::stream::*;
use hyper::Client;
use hyper::client::HttpConnector;
use hyper::Client;
use hyper_tls::HttpsConnector;
use tokio_core::reactor::Core;
use num_cpus;
use rss;
use rayon;
use rayon_futures::ScopeFutureExt;
use Source;
use dbqueries;
use errors::DataError;
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
use Source;
// use std::sync::{Arc, Mutex};
// http://gtk-rs.org/tuto/closures
#[macro_export]
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
@@ -37,171 +38,81 @@ macro_rules! clone {
);
}
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `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>>(
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
pub fn pipeline<'a, S>(
sources: S,
ignore_etags: bool,
tokio_core: &mut Core,
pool: &CpuPool,
client: Client<HttpsConnector<HttpConnector>>,
) -> Result<(), DataError> {
let list: Vec<_> = sources
.into_iter()
.map(clone!(pool => move |s| s.into_feed(&client, pool.clone(), ignore_etags)))
.map(|fut| fut.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index()))))
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
.collect();
if list.is_empty() {
return Err(DataError::EmptyFuturesList);
}
// Thats not really concurrent yet I think.
tokio_core.run(collect_futures(list))?;
Ok(())
}
/// Creates a tokio `reactor::Core`, a `CpuPool`, and a `hyper::Client` and runs the pipeline.
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
if sources.is_empty() {
return Ok(());
}
let pool = CpuPool::new_num_cpus();
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
pipeline(sources, ignore_etags, &mut core, &pool, client)
}
/// Docs
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
let pool = CpuPool::new_num_cpus();
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
let work = s.into_feed(&client, pool.clone(), ignore_etags)
.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index())))
.map(|_| ());
core.run(work)
}
fn determine_ep_state(
ep: NewEpisodeMinimal,
item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
if !exists {
Ok(IndexState::Index(ep.into_new_episode(item)))
} else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
let rowid = old.rowid();
if ep != old {
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
} else {
Ok(IndexState::NotChanged)
}
}
}
pub(crate) fn glue_async<'a>(
item: &'a rss::Item,
id: i32,
) -> Box<Future<Item = IndexState<NewEpisode>, Error = DataError> + 'a> {
Box::new(
result(NewEpisodeMinimal::new(item, id)).and_then(move |ep| determine_ep_state(ep, item)),
)
}
// Weird magic from #rust irc channel
// kudos to remexre
/// FIXME: Docs
#[cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
pub fn collect_futures<F>(
futures: Vec<F>,
) -> Box<Future<Item = Vec<Result<F::Item, F::Error>>, Error = DataError>>
client: &HttpsClient,
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a
where
F: 'static + Future,
<F as Future>::Item: 'static,
<F as Future>::Error: 'static,
S: Stream<Item = Source, Error = DataError> + 'a,
{
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)))
sources
.and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags)))
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index())))
// the stream will stop at the first error so
// we ensure that everything will succeded regardless.
.map_err(|err| error!("Error: {}", err))
.then(|_| ok::<(), DataError>(()))
.collect()
}
})
}))
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
pub fn run<S>(sources: S, ignore_etags: bool) -> Result<(), DataError>
where
S: IntoIterator<Item = Source>,
{
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
let stream = iter_ok::<_, DataError>(sources);
let p = pipeline(stream, ignore_etags, &client);
core.run(p).map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use Source;
use database::truncate_db;
use dbqueries;
use Source;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
const URLS: &[&str] = &[
"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/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
"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/20180120104957if_/https://rss.art19.com/steal-the-stars",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
];
#[test]
/// Insert feeds and update/index them.
fn test_pipeline() {
truncate_db().unwrap();
URLS.iter().for_each(|&(_, url)| {
let bad_url = "https://gitlab.gnome.org/World/hammond.atom";
// if a stream returns error/None it stops
// bad we want to parse all feeds regardless if one fails
Source::from_url(bad_url).unwrap();
URLS.iter().for_each(|url| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
let sources = dbqueries::get_sources().unwrap();
run(sources, true).unwrap();
@@ -210,7 +121,7 @@ mod tests {
run(sources, true).unwrap();
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_sources().unwrap().len(), 6);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
+25 -61
View File
@@ -3,7 +3,6 @@
use chrono::prelude::*;
use rayon::prelude::*;
use itertools::Itertools;
use url::{Position, Url};
use dbqueries;
@@ -14,7 +13,8 @@ use xdg_dirs::DL_DIR;
use std::fs;
use std::path::Path;
/// Scan downloaded `episode` entries that might have broken `local_uri`s and set them to `None`.
/// Scan downloaded `episode` entries that might have broken `local_uri`s and
/// set them to `None`.
fn download_checker() -> Result<(), DataError> {
let mut episodes = dbqueries::get_downloaded_episodes()?;
@@ -28,34 +28,31 @@ 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.
fn played_cleaner() -> Result<(), DataError> {
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;
let now_utc = Utc::now().timestamp() as i32;
episodes
.par_iter_mut()
.filter(|ep| ep.local_uri().is_some() && ep.played().is_some())
.for_each(|ep| {
// TODO: expose a config and a user set option.
// Chnage the test too when exposed
let limit = ep.played().unwrap() + 172_800; // add 2days in seconds
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 succesfully.", ep.local_uri()))
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri()))
.ok();
}
});
Ok(())
@@ -91,10 +88,10 @@ fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
///
/// Runs a cleaner for played Episode's that are pass the lifetime limit and
/// scheduled for removal.
pub fn checkup() -> Result<(), DataError> {
pub fn checkup(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
info!("Running database checks.");
download_checker()?;
played_cleaner()?;
played_cleaner(cleanup_date)?;
info!("Checks completed.");
Ok(())
}
@@ -111,22 +108,6 @@ pub fn url_cleaner(s: &str) -> String {
}
}
/// Helper functions that strips extra spaces and newlines and ignores the tabs.
#[allow(match_same_arms)]
pub fn replace_extra_spaces(s: &str) -> String {
s.trim()
.chars()
.filter(|ch| *ch != '\t')
.coalesce(|current, next| match (current, next) {
('\n', '\n') => Ok('\n'),
('\n', ' ') => Ok('\n'),
(' ', '\n') => Ok('\n'),
(' ', ' ') => Ok(' '),
(_, _) => Err((current, next)),
})
.collect::<String>()
}
/// Returns the URI of a Podcast Downloads given it's title.
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
@@ -181,6 +162,7 @@ mod tests {
use self::tempdir::TempDir;
use super::*;
use chrono::Duration;
use database::truncate_db;
use models::NewEpisodeBuilder;
@@ -260,15 +242,14 @@ mod tests {
fn test_played_cleaner_expired() {
let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let now_utc = Utc::now().timestamp() as i32;
// let limit = now_utc - 172_800;
let epoch = now_utc - 200_000;
let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 - 1;
episode.set_played(Some(epoch));
episode.save().unwrap();
let valid_path = episode.local_uri().unwrap().to_owned();
// This should delete the file
played_cleaner().unwrap();
played_cleaner(cleanup_date).unwrap();
assert_eq!(Path::new(&valid_path).exists(), false);
}
@@ -276,15 +257,14 @@ mod tests {
fn test_played_cleaner_none() {
let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let now_utc = Utc::now().timestamp() as i32;
// limit = 172_800;
let epoch = now_utc - 20_000;
let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 + 1;
episode.set_played(Some(epoch));
episode.save().unwrap();
let valid_path = episode.local_uri().unwrap().to_owned();
// This should not delete the file
played_cleaner().unwrap();
played_cleaner(cleanup_date).unwrap();
assert_eq!(Path::new(&valid_path).exists(), true);
}
@@ -299,24 +279,8 @@ mod tests {
}
#[test]
fn test_whitespace() {
let bad_txt = "1 2 3 4 5";
let valid_txt = "1 2 3 4 5";
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
let bad_txt = "1 2 3 \n 4 5\n";
let valid_txt = "1 2 3\n4 5";
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
let bad_txt = "1 2 3 \n\n\n \n 4 5\n";
let valid_txt = "1 2 3\n4 5";
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
}
#[test]
// This test needs access to local system so we ignore it by default.
#[ignore]
fn test_get_dl_folder() {
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
assert_eq!(get_download_folder("foo").unwrap(), foo_);
@@ -0,0 +1,73 @@
<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>
<title>Ελληνοφρένεια</title>
<link>https://ellinofreneia.sealabs.net/feed.rss</link>
<description>Ανεπίσημο feed της Ελληνοφρένειας</description>
<atom:link href="https://ellinofreneia.sealabs.net/feed.rss" rel="self"/>
<copyright>All rights reversed by http://ellinofreneianet.gr/</copyright>
<docs>http://www.rssboard.org/rss-specification</docs>
<generator>python-feedgen</generator>
<image>
<url>https://ellinofreneia.sealabs.net/logo.png</url>
<title>Ελληνοφρένεια</title>
<link>https://ellinofreneia.sealabs.net/feed.rss</link>
</image>
<language>el</language>
<lastBuildDate>Tue, 27 Mar 2018 13:00:56 +0000</lastBuildDate>
<pubDate>Tue, 27 Mar 2018 13:00:56 +0000</pubDate>
<itunes:explicit>no</itunes:explicit>
<itunes:owner>
<itunes:name>Τζένη Μπώτση</itunes:name>
<itunes:email>tbotsi@example.com</itunes:email>
</itunes:owner>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 27ης Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="36087430" type="audio/mpeg"/>
<pubDate>Tue, 27 Mar 2018 11:11:02 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2027%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2257</itunes:duration>
</item>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 26ης Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="34974405" type="audio/mpeg"/>
<pubDate>Mon, 26 Mar 2018 11:16:17 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2026%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2188</itunes:duration>
</item>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 23ης Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="38491114" type="audio/mpeg"/>
<pubDate>Fri, 23 Mar 2018 12:11:08 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2023%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2408</itunes:duration>
</item>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 22ας Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="35607613" type="audio/mpeg"/>
<pubDate>Thu, 22 Mar 2018 12:31:20 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2022%CE%B1%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2227</itunes:duration>
</item>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 21ης Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="36459832" type="audio/mpeg"/>
<pubDate>Wed, 21 Mar 2018 12:10:59 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2021%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2281</itunes:duration>
</item>
<item>
<title>Η ρ_φ Ελληνοφρένεια της 20ης Μαρτίου 2018</title>
<guid isPermaLink="false">https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3</guid>
<enclosure url="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.mp3" length="34507127" type="audio/mpeg"/>
<pubDate>Tue, 20 Mar 2018 12:11:06 +0000</pubDate>
<itunes:image href="https://ellinofreneia.sealabs.net/audio/%CE%97%20%CF%81_%CF%86%20%CE%95%CE%BB%CE%BB%CE%B7%CE%BD%CE%BF%CF%86%CF%81%CE%AD%CE%BD%CE%B5%CE%B9%CE%B1%20%CF%84%CE%B7%CF%82%2020%CE%B7%CF%82%20%CE%9C%CE%B1%CF%81%CF%84%CE%AF%CE%BF%CF%85%202018.jpg"/>
<itunes:duration>2159</itunes:duration>
</item>
</channel>
</rss>
+5
View File
@@ -38,3 +38,8 @@ Web view: https://web.archive.org/web/20180120104741/https://www.greaterthancode
Raw file: https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast
#### Ellinofreneia
Web view: https://web.archive.org/web/20180328083913/https://ellinofreneia.sealabs.net/audio/podcast.rss
Raw file: https://web.archive.org/web/20180328083913if_/https://ellinofreneia.sealabs.net/audio/podcast.rss
+6 -4
View File
@@ -6,11 +6,11 @@ workspace = "../"
[dependencies]
error-chain = "0.11.0"
hyper = "0.11.18"
hyper = "0.11.27"
log = "0.4.1"
mime_guess = "1.8.3"
reqwest = "0.8.4"
tempdir = "0.3.6"
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"
@@ -18,3 +18,5 @@ failure_derive = "0.1.1"
[dependencies.hammond-data]
path = "../hammond-data"
[dev-dependencies]
pretty_assertions = "0.5.1"
+13 -7
View File
@@ -11,14 +11,15 @@ use std::io::{BufWriter, Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
use hammond_data::xdg_dirs::HAMMOND_CACHE;
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
// use failure::Error;
use errors::DownloadError;
// TODO: Replace path that are of type &str with std::path.
// TODO: Have a convention/document absolute/relative paths, if they should end with / or not.
// TODO: Have a convention/document absolute/relative paths, if they should end
// with / or not.
pub trait DownloadProgress {
fn set_downloaded(&mut self, downloaded: u64);
@@ -75,7 +76,8 @@ fn download_into(
info!("Extension: {}", ext);
// Construct a temp file to save desired content.
// It has to be a `new_in` instead of new cause rename can't move cross filesystems.
// It has to be a `new_in` instead of new cause rename can't move cross
// filesystems.
let tempdir = TempDir::new_in(HAMMOND_CACHE.to_str().unwrap(), "temp_download")?;
let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),);
@@ -113,7 +115,9 @@ fn get_ext(content: Option<ContentType>) -> Option<String> {
// TODO: Write unit-tests.
// TODO: Refactor... Somehow.
/// Handles the I/O of fetching a remote file and saving into a Buffer and A File.
/// Handles the I/O of fetching a remote file and saving into a Buffer and A
/// File.
#[allow(needless_pass_by_value)]
fn save_io(
file: &str,
resp: &mut reqwest::Response,
@@ -193,7 +197,8 @@ pub fn get_episode(
}
pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
let url = pd.image_uri()
let url = pd
.image_uri()
.ok_or_else(|| DownloadError::NoImageLocation)?
.to_owned();
@@ -211,7 +216,8 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
// For some reason there is no .first() method so nth(0) is used
let path = foo.nth(0).and_then(|x| x.ok());
if let Some(p) = path {
return Ok(p.to_str()
return Ok(p
.to_str()
.ok_or_else(|| DownloadError::InvalidCachedImageLocation)?
.into());
}
@@ -230,9 +236,9 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
#[cfg(test)]
mod tests {
use super::*;
use hammond_data::Source;
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::Source;
use std::fs;
+8 -1
View File
@@ -1,5 +1,8 @@
#![recursion_limit = "1024"]
#![deny(unused_extern_crates, unused)]
#![warn(unused_extern_crates, unused)]
#![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name, option_map_unit_fn))]
#![deny(warnings)]
extern crate failure;
#[macro_use]
@@ -7,6 +10,10 @@ 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;
+21 -11
View File
@@ -6,29 +6,39 @@ version = "0.1.0"
workspace = "../"
[dependencies]
chrono = "0.4.0"
dissolve = "0.2.2"
gdk = "0.7.0"
gdk-pixbuf = "0.3.0"
gio = "0.3.0"
glib = "0.4.1"
chrono = "0.4.2"
crossbeam-channel = "0.1.2"
gdk = "0.8.0"
gdk-pixbuf = "0.4.0"
glib = "0.5.0"
humansize = "1.1.0"
lazy_static = "1.0.0"
log = "0.4.1"
loggerv = "0.7.0"
loggerv = "0.7.1"
open = "1.2.1"
rayon = "0.9.0"
send-cell = "0.1.2"
url = "1.6.0"
rayon = "1.0.1"
send-cell = "0.1.3"
url = "1.7.0"
failure = "0.1.1"
failure_derive = "0.1.1"
regex = "1.0.0"
reqwest = "0.8.5"
serde_json = "1.0.17"
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
[dependencies.gtk]
features = ["v3_22"]
version = "0.3.0"
version = "0.4.1"
[dependencies.gio]
features = ["v2_50"]
version = "0.4.0"
[dependencies.hammond-data]
path = "../hammond-data"
[dependencies.hammond-downloader]
path = "../hammond-downloader"
[dev-dependencies]
pretty_assertions = "0.5.1"
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="empty_show">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">application-rss+xml-symbolic</property>
<property name="use_fallback">True</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">This show does not have any episodes</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.4399999999999999"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">If you think this is an Error, Plese consider opening a bug report.</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>
+61 -55
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -31,6 +31,7 @@ Tobias Bernard
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="episode_container">
<property name="height_request">64</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
@@ -40,36 +41,22 @@ Tobias Bernard
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<object class="GtkBox" id="info_container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">start</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</property>
<property name="width_chars">55</property>
<property name="single_line_mode">True</property>
<property name="max_width_chars">64</property>
<property name="track_visited_links">False</property>
<property name="lines">1</property>
<property name="xalign">0</property>
<property name="yalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -81,7 +68,6 @@ Tobias Bernard
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="date_label">
@@ -96,7 +82,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -112,7 +98,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
@@ -129,7 +115,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
@@ -145,7 +131,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">3</property>
</packing>
</child>
@@ -162,7 +148,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">4</property>
</packing>
</child>
@@ -179,7 +165,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">5</property>
</packing>
</child>
@@ -196,43 +182,73 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">6</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<object class="GtkButtonBox" id="button_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<property name="layout_style">center</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<object class="GtkButton" id="desc_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">view-paged-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkButton" id="play_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Play this episode</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">media-playback-start-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
<property name="position">2</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
<child>
@@ -240,9 +256,8 @@ Tobias Bernard
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Download this episode</property>
<property name="halign">end</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="always_show_image">True</property>
<child>
@@ -256,24 +271,17 @@ Tobias Bernard
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
<property name="position">3</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkButton" id="play_button">
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Play this episode</property>
<property name="receives_default">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">media-playback-start-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -285,8 +293,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">6</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
@@ -295,7 +302,6 @@ Tobias Bernard
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
@@ -305,13 +311,13 @@ Tobias Bernard
<property name="no_show_all">True</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_bottom">6</property>
<property name="hexpand">True</property>
<property name="pulse_step">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="padding">6</property>
<property name="position">1</property>
</packing>
</child>
+45 -29
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -32,6 +32,8 @@ Tobias Bernard
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="name">container</property>
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
@@ -40,7 +42,7 @@ Tobias Bernard
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
@@ -49,37 +51,38 @@ Tobias Bernard
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</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">True</property>
<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">720</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</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>
@@ -87,6 +90,7 @@ Tobias Bernard
<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"/>
@@ -95,7 +99,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -103,12 +107,14 @@ Tobias Bernard
<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>
@@ -118,22 +124,22 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</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>
@@ -149,7 +155,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -157,12 +163,14 @@ Tobias Bernard
<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>
@@ -172,22 +180,22 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</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>
@@ -203,7 +211,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -211,12 +219,14 @@ Tobias Bernard
<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>
@@ -226,22 +236,22 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</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>
@@ -257,7 +267,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -265,12 +275,14 @@ Tobias Bernard
<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>
@@ -280,22 +292,22 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</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>
@@ -303,6 +315,7 @@ Tobias Bernard
<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"/>
@@ -311,7 +324,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -319,6 +332,7 @@ Tobias Bernard
<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>
@@ -326,6 +340,7 @@ Tobias Bernard
<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>
@@ -335,14 +350,14 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">5</property>
</packing>
</child>
@@ -357,13 +372,14 @@ Tobias Bernard
<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">True</property>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -33,14 +33,15 @@ Tobias Bernard
<object class="GtkBox" id="container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="pixel_size">64</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
+4 -98
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -168,7 +168,7 @@ Tobias Bernard
</object>
</child>
<child>
<object class="GtkButton" id="back_button">
<object class="GtkButton" id="back">
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
@@ -257,12 +257,12 @@ Tobias Bernard
</object>
</child>
<child>
<object class="GtkMenuButton" id="menu_toggle">
<object class="GtkMenuButton" id="menu_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="valign">center</property>
<property name="popover">menu_popover</property>
<property name="use_popover">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
@@ -281,98 +281,4 @@ Tobias Bernard
</packing>
</child>
</object>
<object class="GtkPopover" id="menu_popover">
<property name="can_focus">False</property>
<property name="relative_to">menu_toggle</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Preferences</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="update_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Check for new episodes</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="about_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">About</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Help</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">7</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Keyboard Shortcuts</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">8</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<object class="GtkShortcutsWindow" id="help_overlay">
<property name="modal">True</property>
<child>
<object class="GtkShortcutsSection">
<property name="section-name">shortcuts</property>
<property name="max-height">12</property>
<property name="visible">True</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">General</property>
<property name="visible">True</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">True</property>
<property name="accelerator">&lt;primary&gt;r</property>
<property name="title" translatable="yes" context="shortcut window">Check for new episodes</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">True</property>
<property name="accelerator">&lt;primary&gt;q</property>
<property name="title" translatable="yes" context="shortcut window">Quit the application</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkRevealer" id="revealer">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_left">3</property>
<property name="margin_right">3</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="text">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_left">12</property>
<property name="margin_right">12</property>
<property name="label" translatable="yes">An in-app action notification</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="close">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="relief">none</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">window-close-symbolic</property>
</object>
</child>
<style>
<class name="flat image-button"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="undo">
<property name="label" translatable="yes">Undo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="text-button"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
<style>
<class name="app-notification"/>
</style>
</object>
</child>
<style>
<class name="top"/>
</style>
</object>
</interface>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<interface>
<!-- interface-requires gtk+ 3.0 -->
<menu id="menu">
<section>
<item>
<attribute name="label" translatable="yes">_Check for new episodes</attribute>
<attribute name="action">app.refresh</attribute>
<attribute name="accel">&lt;primary&gt;r</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Import Shows</attribute>
<attribute name="action">app.import</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Export Shows</attribute>
<attribute name="action">app.export</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Preferences</attribute>
<attribute name="action">app.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
<attribute name="action">win.show-help-overlay</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Help</attribute>
<attribute name="action">app.help</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_About</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</menu>
</interface>
+82 -64
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -24,13 +24,15 @@ Tobias Bernard
-->
<interface domain="">
<requires lib="gtk+" version="3.12"/>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
@@ -39,7 +41,8 @@ Tobias Bernard
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="vexpand">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
@@ -48,85 +51,85 @@ Tobias Bernard
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</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">True</property>
<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">624</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_top">32</property>
<property name="margin_bottom">32</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</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>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<property name="min_content_height">80</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child type="center">
<property name="shadow_type">none</property>
<child>
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">end</property>
<property name="label" translatable="yes">Show description</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label">This is embarrasing!
Sorry, we could not find a description for this Show.</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max_width_chars">57</property>
<attributes>
<attribute name="weight" value="medium"/>
</attributes>
<property name="max_width_chars">70</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
@@ -134,7 +137,7 @@ Tobias Bernard
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">5</property>
<property name="spacing">6</property>
<child>
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
@@ -196,44 +199,28 @@ Tobias Bernard
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="episodes">
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<placeholder/>
<object class="GtkListBox" id="episodes">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
@@ -241,8 +228,8 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
@@ -256,14 +243,16 @@ Tobias Bernard
<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">True</property>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
@@ -273,10 +262,39 @@ Tobias Bernard
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<object class="GtkPopover" id="show_menu">
<property name="can_focus">False</property>
<property name="position">bottom</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton" id="mark_all_watched">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Mark all episodes as listened</property>
<property name="centered">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
+2 -11
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -39,13 +39,6 @@ Tobias Bernard
<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>
@@ -55,10 +48,8 @@ Tobias Bernard
<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="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
-1
View File
@@ -40,7 +40,6 @@ Tobias Bernard
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
+2
View File
@@ -2,3 +2,5 @@
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')
@@ -5,19 +5,32 @@
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<developer_name>Jordan Petridis</developer_name>
<summary>GNOME Podcast Client written in Rust</summary>
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
<description>
Hammond is a Gtk+ Podcast client for the GNOME Desktop written in Rust
<summary>Modern Podcast Client for the GNOME desktop</summary>
<description xml:lang="en">
<p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
desktop written in Rust.
</p>
</description>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<screenshots>
<screenshot>
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcasts_view.png</image>
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcast_widget.png</image>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.3.0" date="2018-02-11"/>
<release version="0.3.4" date="2018-05-20"/>
</releases>
<update_contact>jordanpetridis@protonmail.com</update_contact>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name>
</component>
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<enum id="org.gnome.Hammond.timePeriods">
<value nick="seconds" value="1"/>
<value nick="minutes" value="2"/>
<value nick="hours" value="3"/>
<value nick="days" value="4"/>
<value nick="weeks" value="5"/>
<value nick="months" value="6"/>
</enum>
<schema path="/org/gnome/hammond/" id="org.gnome.Hammond">
<key name="persist-window-geometry-top" type="i">
<default>-1</default>
<summary>Top position of the last open main window</summary>
</key>
<key name="persist-window-geometry-left" type="i">
<default>-1</default>
<summary>Left position of the last open main window</summary>
</key>
<key name="persist-window-geometry-height" type="i">
<default>640</default>
<summary>Height of the last open main window</summary>
</key>
<key name="persist-window-geometry-width" type="i">
<default>860</default>
<summary>Width of the last open main window</summary>
</key>
<key name="persist-window-geometry-maximized" type="b">
<default>false</default>
<summary>Maximized state of the last open main window</summary>
</key>
<key name="dark-theme" type="b">
<default>false</default>
<summary>Enable or disable dark theme</summary>
</key>
<key name="refresh-interval" type="b">
<default>true</default>
<summary>Whether to periodically refresh content</summary>
</key>
<key name="refresh-interval-time" type="i">
<range min="1" max="100"/>
<default>1</default>
<summary>How many periods of time to wait between automatic refreshes</summary>
</key>
<key name="refresh-interval-period" enum="org.gnome.Hammond.timePeriods">
<default>'hours'</default>
<summary>What period of time to wait between automatic refreshes</summary>
</key>
<key name="refresh-on-startup" type="b">
<default>true</default>
<summary>Whether to refresh content after startup</summary>
</key>
<key name="cleanup-age-time" type="i">
<range min="1" max="100"/>
<default>2</default>
<summary>How many periods of time to wait between automatic cleanups</summary>
</key>
<key name="cleanup-age-period" enum="org.gnome.Hammond.timePeriods">
<default>'days'</default>
<summary>What period of time to wait between automatic cleanups</summary>
</key>
</schema>
</schemalist>
+13 -9
View File
@@ -1,14 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/hammond/">
<file preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/empty_view.ui</file>
<file preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
<file preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/shows_view.ui</file>
<file preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<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" preprocess="xml-stripblanks">gtk/menus.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
<file compressed="true">gtk/style.css</file>
</gresource>
</gresources>
+234 -103
View File
@@ -1,28 +1,47 @@
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
#![allow(new_without_default)]
use gio::{
ActionMapExt, ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt,
SimpleAction, SimpleActionExt,
};
use glib;
use gtk;
use gtk::prelude::*;
use gtk::SettingsExt as GtkSettingsExt;
use hammond_data::{Podcast, Source};
use hammond_data::utils::checkup;
use crossbeam_channel::{unbounded, Sender};
use hammond_data::{Podcast, dbqueries};
use headerbar::Header;
use stacks::Content;
use settings::{self, WindowGeometry};
use stacks::{Content, PopulatedState};
use utils;
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::{about_dialog, mark_all_notif, remove_show_notif};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration;
#[derive(Clone, Debug)]
/// Creates an action named $called in the action map $on with the handler $handle
macro_rules! action {
($on:expr, $called:expr, $handle:expr) => {{
// Create a stateless, parameterless action
let act = SimpleAction::new($called, None);
// Connect the handler
act.connect_activate($handle);
// Add it to the map
$on.add_action(&act);
// Return the action
act
}};
}
#[derive(Debug, Clone)]
pub enum Action {
UpdateSources(Option<Source>),
RefreshAllViews,
RefreshEpisodesView,
RefreshEpisodesViewBGR,
RefreshShowsView,
RefreshWidget,
RefreshWidgetIfVis,
ReplaceWidget(Arc<Podcast>),
RefreshWidgetIfSame(i32),
ShowWidgetAnimated,
@@ -31,20 +50,21 @@ pub enum Action {
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>),
ErrorNotification(String),
AboutWidget(i32),
}
#[derive(Debug)]
pub struct App {
app_instance: gtk::Application,
window: gtk::Window,
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...");
@@ -52,117 +72,228 @@ impl App {
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
// Create the main window
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_default_size(860, 640);
window.set_title("Hammond");
let app_clone = application.clone();
window.connect_delete_event(move |_, _| {
app_clone.quit();
Inhibit(false)
});
let cleanup_date = settings::get_cleanup_date(&settings);
utils::cleanup(cleanup_date);
let (sender, receiver) = channel();
application.connect_startup(clone!(settings => move |app| {
let (sender, receiver) = unbounded();
App::setup_gactions(&app, &sender);
app.connect_activate(clone!(sender, settings => move |app| {
// Get the current window (if any)
if let Some(window) = app.get_active_window() {
// Already open, just raise the window
window.present();
} else {
// Time to open one!
// Create the main window
let window = gtk::ApplicationWindow::new(&app);
window.set_title("Hammond");
window.connect_delete_event(clone!(app, settings => move |window, _| {
WindowGeometry::from_window(&window).write(&settings);
app.quit();
Inhibit(false)
}));
// Create a content instance
let content =
Arc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
Rc::new(Content::new(sender.clone()).expect(
"Content Initialization failed."));
// Create the headerbar
let header = Arc::new(Header::new(&content, &window, sender.clone()));
let header = Rc::new(Header::new(&content, &window, &sender));
// Add the content main stack to the window.
window.add(&content.get_stack());
action!(window, "menu", clone!(header => move |_, _| {
header.open_menu();
}));
App {
app_instance: application,
window,
header,
content,
receiver,
sender,
}
}
// Add the content main stack to the overlay.
let overlay = gtk::Overlay::new();
overlay.add(&content.get_stack());
pub fn setup_timed_callbacks(&self) {
let sender = self.sender.clone();
// Update the feeds right after the Application is initialized.
gtk::timeout_add_seconds(2, move || {
utils::refresh_feed_wrapper(None, sender.clone());
glib::Continue(false)
});
// Add the overlay to the main window
window.add(&overlay);
let sender = self.sender.clone();
// Auto-updater, runs every hour.
// TODO: expose the interval in which it run to a user setting.
gtk::timeout_add_seconds(3600, move || {
utils::refresh_feed_wrapper(None, sender.clone());
glib::Continue(true)
});
WindowGeometry::from_settings(&settings).apply(&window);
// Run a database checkup once the application is initialized.
gtk::timeout_add(300, || {
if let Err(err) = checkup() {
error!("Check up failed: {}", err);
}
App::setup_timed_callbacks(&sender, &settings);
glib::Continue(false)
});
}
window.show_all();
window.activate();
pub fn run(self) {
let window = self.window.clone();
let app = self.app_instance.clone();
self.app_instance.connect_startup(move |_| {
build_ui(&window, &app);
});
self.setup_timed_callbacks();
let content = self.content.clone();
let headerbar = self.header.clone();
let sender = self.sender.clone();
let receiver = self.receiver;
gtk::idle_add(move || {
match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(Action::UpdateSources(source)) => {
if let Some(s) = source {
utils::refresh_feed_wrapper(Some(vec![s]), sender.clone());
} else {
utils::refresh_feed_wrapper(None, sender.clone());
}
}
gtk::timeout_add(50, clone!(sender, receiver => move || {
// Uses receiver, content, header, sender, overlay
match receiver.try_recv() {
Ok(Action::RefreshAllViews) => content.update(),
Ok(Action::RefreshShowsView) => content.update_shows_view(),
Ok(Action::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::RefreshWidgetIfSame(id)) =>
content.update_widget_if_same(id),
Ok(Action::RefreshEpisodesView) => content.update_home(),
Ok(Action::RefreshEpisodesViewBGR) =>
content.update_home_if_background(),
Ok(Action::ReplaceWidget(pd)) => {
if let Err(err) = content.get_shows().replace_widget(pd) {
error!("Something went wrong while trying to update the ShowWidget.");
error!("Error: {}", err);
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.replace_widget(pd.clone())
.map_err(|err| error!("Failed to update ShowWidget: {}", err))
.map_err(|_|
error!("Failed ot update ShowWidget {}", pd.title()))
.ok();
}
Ok(Action::ShowWidgetAnimated) => {
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut().switch_visible(
PopulatedState::Widget,
gtk::StackTransitionType::SlideLeft,
);
}
Ok(Action::ShowShowsAnimated) => {
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.switch_visible(PopulatedState::View,
gtk::StackTransitionType::SlideRight);
}
Ok(Action::HeaderBarShowTile(title)) =>
header.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => header.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) =>
header.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) =>
header.hide_update_notification(),
Ok(Action::MarkAllPlayerNotification(pd)) => {
let notif = mark_all_notif(pd, &sender);
notif.show(&overlay);
}
Ok(Action::RemoveShow(pd)) => {
let notif = remove_show_notif(pd, sender.clone());
notif.show(&overlay);
}
Ok(Action::ErrorNotification(err)) => {
error!("An error notification was triggered: {}", err);
let callback = || glib::Continue(false);
let notif = InAppNotification::new(&err, callback,
|| {}, UndoState::Hidden);
notif.show(&overlay);
}
Ok(Action::AboutWidget(rowid)) => {
if let Ok(d) = dbqueries::get_episode_description(rowid) {
if let Some(_desc) = d {
let _stack = content.get_visible_stack();
}
}
}
Ok(Action::ShowWidgetAnimated) => content.get_shows().switch_widget_animated(),
Ok(Action::ShowShowsAnimated) => content.get_shows().switch_podcasts_animated(),
Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
Err(_) => (),
}
Continue(true)
});
}));
}
}));
}));
App {
app_instance: application,
settings,
}
}
fn setup_timed_callbacks(sender: &Sender<Action>, settings: &Settings) {
App::setup_dark_theme(settings);
App::setup_refresh_on_startup(&sender, settings);
App::setup_auto_refresh(&sender, settings);
}
fn setup_dark_theme(settings: &Settings) {
let gtk_settings = gtk::Settings::get_default().unwrap();
let enabled = settings.get_boolean("dark-theme");
gtk_settings.set_property_gtk_application_prefer_dark_theme(enabled);
}
fn setup_refresh_on_startup(sender: &Sender<Action>, settings: &Settings) {
// Update the feeds right after the Application is initialized.
let sender = sender.clone();
if settings.get_boolean("refresh-on-startup") {
info!("Refresh on startup.");
// The ui loads async, after initialization
// so we need to delay this a bit so it won't block
// requests that will come from loading the gui on startup.
gtk::timeout_add(1500, move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
});
}
}
fn setup_auto_refresh(sender: &Sender<Action>, settings: &Settings) {
let refresh_interval = settings::get_refresh_interval(&settings).num_seconds() as u32;
info!("Auto-refresh every {:?} seconds.", refresh_interval);
let sender = sender.clone();
gtk::timeout_add_seconds(refresh_interval, move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(true)
});
}
/// Define the `GAction`s.
///
/// Used in menus and the keyboard shortcuts dialog.
fn setup_gactions(app: &gtk::Application, sender: &Sender<Action>) {
// Create the `refresh` action.
//
// This will trigger a refresh of all the shows in the database.
action!(
app,
"refresh",
clone!(sender => move |_, _| {
gtk::idle_add(clone!(sender => move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
}));
})
);
app.set_accels_for_action("app.refresh", &["<primary>r"]);
// Create the `OPML` import action
action!(
app,
"import",
clone!(sender, app => move |_, _| {
let window = app.get_active_window().expect("Failed to get active window");
utils::on_import_clicked(&window, &sender);
})
);
// Create the action that shows a `gtk::AboutDialog`
action!(
app,
"about",
clone!(app => move |_, _| {
let window = app.get_active_window().expect("Failed to get active window");
about_dialog(&window);
})
);
// Create the quit action
action!(app, "quit", clone!(app => move |_, _| app.quit()));
app.set_accels_for_action("app.quit", &["<primary>q"]);
// Bind the hamburger menu button to `F10`
app.set_accels_for_action("win.menu", &["F10"]);
}
pub fn run(self) {
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 |_| ());
}
+70 -87
View File
@@ -1,3 +1,5 @@
use gio::MenuModel;
use glib;
use gtk;
use gtk::prelude::*;
@@ -5,68 +7,78 @@ use failure::Error;
use failure::ResultExt;
use url::Url;
use hammond_data::Source;
use hammond_data::dbqueries;
use std::sync::mpsc::Sender;
use crossbeam_channel::Sender;
use hammond_data::{dbqueries, Source};
use app::Action;
use stacks::Content;
use utils::{itunes_to_rss, refresh};
#[derive(Debug, Clone)]
// TODO: split this into smaller
pub struct Header {
container: gtk::HeaderBar,
add_toggle: gtk::MenuButton,
switch: gtk::StackSwitcher,
back_button: gtk::Button,
back: 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,
menu_button: gtk::MenuButton,
app_menu: MenuModel,
}
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
let menus = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/menus.ui");
let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
let 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();
let header = builder.get_object("headerbar").unwrap();
let add_toggle = builder.get_object("add_toggle").unwrap();
let switch = builder.get_object("switch").unwrap();
let back = builder.get_object("back").unwrap();
let show_title = builder.get_object("show_title").unwrap();
let update_box = builder.get_object("update_notification").unwrap();
let update_label = builder.get_object("update_label").unwrap();
let update_spinner = builder.get_object("update_spinner").unwrap();
let menu_button = builder.get_object("menu_button").unwrap();
let app_menu = menus.get_object("menu").unwrap();
Header {
container: header,
add_toggle,
switch,
back_button,
back,
show_title,
about_button,
update_button,
update_box,
update_label,
update_spinner,
menu_button,
app_menu,
}
}
}
// TODO: Refactor components into smaller state machines
impl Header {
pub fn new(content: &Content, window: &gtk::Window, sender: Sender<Action>) -> Header {
pub fn new(
content: &Content,
window: &gtk::ApplicationWindow,
sender: &Sender<Action>,
) -> Header {
let h = Header::default();
h.init(content, window, sender);
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");
pub fn init(
&self,
content: &Content,
window: &gtk::ApplicationWindow,
sender: &Sender<Action>,
) {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
@@ -75,55 +87,45 @@ impl Header {
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);
}
on_url_change(url, &result_label, &add_button)
.map_err(|err| error!("Error: {}", err))
.ok();
}));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
if let Err(err) = on_add_bttn_clicked(&new_url, sender.clone()) {
error!("Error: {}", err);
}
on_add_bttn_clicked(&new_url, sender.clone())
.map_err(|err| error!("Error: {}", err))
.ok();
add_popover.hide();
}));
self.add_toggle.set_popover(&add_popover);
self.update_button
.connect_clicked(clone!(sender => move |_| {
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(
self.back.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);
}
sender.send(Action::ShowShowsAnimated)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}),
);
self.menu_button.set_menu_model(Some(&self.app_menu));
}
pub fn switch_to_back(&self, title: &str) {
self.switch.hide();
self.add_toggle.hide();
self.back_button.show();
self.back.show();
self.set_show_title(title);
self.show_title.show();
}
@@ -131,7 +133,7 @@ impl Header {
pub fn switch_to_normal(&self) {
self.switch.show();
self.add_toggle.show();
self.back_button.hide();
self.back.hide();
self.show_title.hide();
}
@@ -152,19 +154,35 @@ impl Header {
self.update_spinner.hide();
self.update_label.hide();
}
pub fn open_menu(&self) {
self.menu_button.clicked();
}
}
// 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.")?;
gtk::idle_add(move || {
refresh(Some(vec![source.clone()]), sender.clone());
glib::Continue(false)
});
Ok(())
}
// FIXME: THIS SUCKS!
fn on_url_change(
entry: &gtk::Entry,
result: &gtk::Label,
@@ -203,38 +221,3 @@ fn on_url_change(
}
}
}
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
fn about_dialog(window: &gtk::Window) {
// Feel free to add yourself if you contribured.
let authors = &[
"Constantin Nickel",
"Gabriele Musco",
"James Wykeham-Martin",
"Jordan Petridis",
"Julian Sparber",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("A Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.3");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
// dialog.set_website_label("Learn more about Hammond");
dialog.set_transient_for(window);
dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors);
dialog.show();
}
+26 -22
View File
@@ -1,5 +1,10 @@
#![cfg_attr(feature = "cargo-clippy", allow(clone_on_ref_ptr, needless_pass_by_value))]
// #![deny(unused_extern_crates, unused)]
#![cfg_attr(
feature = "cargo-clippy",
allow(clone_on_ref_ptr, blacklisted_name, match_same_arms, option_map_unit_fn)
)]
#![allow(unknown_lints)]
#![warn(unused_extern_crates, unused)]
#![deny(warnings)]
extern crate gdk;
extern crate gdk_pixbuf;
@@ -16,18 +21,25 @@ extern crate lazy_static;
#[macro_use]
extern crate log;
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate chrono;
extern crate dissolve;
extern crate crossbeam_channel;
extern crate hammond_data;
extern crate hammond_downloader;
extern crate html2pango;
extern crate humansize;
extern crate loggerv;
extern crate open;
extern crate rayon;
extern crate regex;
extern crate reqwest;
extern crate send_cell;
extern crate serde_json;
extern crate url;
// extern crate rayon;
// use rayon::prelude::*;
use log::Level;
use gtk::prelude::*;
@@ -51,18 +63,16 @@ macro_rules! clone {
);
}
// 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;
mod stacks;
mod widgets;
pub mod headerbar;
pub mod app;
mod app;
mod headerbar;
pub mod utils;
pub mod manager;
pub mod static_resource;
mod manager;
mod settings;
mod static_resource;
mod utils;
use app::App;
@@ -74,18 +84,12 @@ fn main() {
// Add custom style
let provider = gtk::CssProvider::new();
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/Hammond/gtk/style.css");
gtk::StyleContext::add_provider_for_screen(
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
&provider,
600,
);
// This set's the app to dark mode.
// It wiil be in the user's preference later.
// Uncomment it to run with the dark theme variant.
// let settings = gtk::Settings::get_default().unwrap();
// settings.set_property_gtk_application_prefer_dark_theme(true);
App::new().run();
}
+23 -41
View File
@@ -1,17 +1,14 @@
use failure::Error;
use rayon;
// use hammond_data::Episode;
use hammond_data::dbqueries;
use hammond_downloader::downloader::{get_episode, DownloadProgress};
use app::Action;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::sync::mpsc::Sender;
// use std::sync::atomic::AtomicUsize;
// use std::path::PathBuf;
use std::thread;
// This is messy, undocumented and hacky af.
// I am terrible at writting downloaders and download managers.
@@ -73,51 +70,36 @@ impl DownloadProgress for Progress {
}
lazy_static! {
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> = {
Arc::new(RwLock::new(HashMap::new()))
};
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> =
{ Arc::new(RwLock::new(HashMap::new())) };
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
}
pub fn add(id: i32, directory: &str, sender: Sender<Action>) -> Result<(), Error> {
pub fn add(id: i32, directory: String) -> Result<(), Error> {
// Create a new `Progress` struct to keep track of dl progress.
let prog = Arc::new(Mutex::new(Progress::default()));
{
let mut m = ACTIVE_DOWNLOADS
.write()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
m.insert(id, prog.clone());
}
match ACTIVE_DOWNLOADS.write() {
Ok(mut guard) => guard.insert(id, prog.clone()),
Err(err) => return Err(format_err!("ActiveDonwloads: {}.", err)),
};
let dir = directory.to_owned();
thread::spawn(move || {
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
let pid = episode.podcast_id();
DLPOOL.spawn(move || {
if let Ok(mut episode) = dbqueries::get_episode_widget_from_rowid(id) {
let id = episode.rowid();
if let Err(err) = get_episode(&mut episode.into(), dir.as_str(), Some(prog)) {
error!("Error while trying to download an episode");
error!("Error: {}", err);
}
get_episode(&mut episode, directory.as_str(), Some(prog))
.map_err(|err| error!("Download Failed: {}", err))
.ok();
{
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
info!("Removed: {:?}", m.remove(&id));
}
let foo = m.remove(&id);
debug!("Removed: {:?}", foo);
}
// {
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
// }
// }
sender
.send(Action::RefreshEpisodesView)
.expect("Action channel blew up.");
sender
.send(Action::RefreshWidgetIfSame(pid))
.expect("Action channel blew up.");
}
});
@@ -128,17 +110,16 @@ pub fn add(id: i32, directory: &str, sender: Sender<Action>) -> Result<(), Error
mod tests {
use super::*;
use hammond_data::{Episode, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::utils::get_download_folder;
use hammond_data::{Episode, Source};
use hammond_downloader::downloader::get_episode;
use std::{thread, time};
use std::fs;
use std::path::Path;
use std::sync::mpsc::channel;
use std::{thread, time};
#[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
@@ -160,21 +141,22 @@ mod tests {
// Get an episode
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
let (sender, _rx) = channel();
let download_fold = get_download_folder(&pd.title()).unwrap();
add(episode.rowid(), download_fold.as_str(), sender).unwrap();
let fold2 = download_fold.clone();
add(episode.rowid(), download_fold).unwrap();
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
// Give it soem time to download the file
thread::sleep(time::Duration::from_secs(20));
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
let final_path = format!("{}/{}.mp3", &fold2, episode.rowid());
assert!(Path::new(&final_path).exists());
fs::remove_file(final_path).unwrap();
}
#[test]
// This test needs access to local system so we ignore it by default.
#[ignore]
fn test_dl_steal_the_stars() {
let url =
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
+136
View File
@@ -0,0 +1,136 @@
use gio;
use gio::{Settings, SettingsExt};
use gtk;
use gtk::GtkWindowExt;
use chrono::prelude::*;
use chrono::Duration;
pub struct WindowGeometry {
left: i32,
top: i32,
width: i32,
height: i32,
is_maximized: bool,
}
impl WindowGeometry {
pub fn from_window(window: &gtk::ApplicationWindow) -> WindowGeometry {
let position = window.get_position();
let size = window.get_size();
let left = position.0;
let top = position.1;
let width = size.0;
let height = size.1;
let is_maximized = window.is_maximized();
WindowGeometry {
left,
top,
width,
height,
is_maximized,
}
}
pub fn from_settings(settings: &gio::Settings) -> WindowGeometry {
let top = settings.get_int("persist-window-geometry-top");
let left = settings.get_int("persist-window-geometry-left");
let width = settings.get_int("persist-window-geometry-width");
let height = settings.get_int("persist-window-geometry-height");
let is_maximized = settings.get_boolean("persist-window-geometry-maximized");
WindowGeometry {
left,
top,
width,
height,
is_maximized,
}
}
pub fn apply(&self, window: &gtk::ApplicationWindow) {
if self.width > 0 && self.height > 0 {
window.resize(self.width, self.height);
}
if self.is_maximized {
window.maximize();
} else if self.top > 0 && self.left > 0 {
window.move_(self.left, self.top);
}
}
pub fn write(&self, settings: &gio::Settings) {
settings.set_int("persist-window-geometry-left", self.left);
settings.set_int("persist-window-geometry-top", self.top);
settings.set_int("persist-window-geometry-width", self.width);
settings.set_int("persist-window-geometry-height", self.height);
settings.set_boolean("persist-window-geometry-maximized", self.is_maximized);
}
}
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = i64::from(settings.get_int("refresh-interval-time"));
let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str())
}
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = i64::from(settings.get_int("cleanup-age-time"));
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
}
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period {
"weeks" => Duration::weeks(time),
"days" => Duration::days(time),
"hours" => Duration::hours(time),
"minutes" => Duration::minutes(time),
_ => Duration::seconds(time),
}
}
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test]
// fn test_apply_window_geometry() {
// gtk::init().expect("Error initializing gtk.");
// let window = gtk::Window::new(gtk::WindowType::Toplevel);
// let _geometry = WindowGeometry {
// left: 0,
// top: 0,
// width: 100,
// height: 100,
// is_maximized: true
// };
// assert!(!window.is_maximized());
// window.show();
// window.activate();
// geometry.apply(&window);
// assert!(window.is_maximized());
// }
+45 -47
View File
@@ -1,94 +1,92 @@
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use app::Action;
use stacks::EpisodeStack;
use stacks::ShowStack;
use stacks::{HomeStack, ShowStack};
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone)]
pub struct Content {
stack: gtk::Stack,
shows: Arc<ShowStack>,
episodes: Arc<EpisodeStack>,
shows: Rc<RefCell<ShowStack>>,
home: Rc<RefCell<HomeStack>>,
sender: Sender<Action>,
}
impl Content {
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
let stack = gtk::Stack::new();
let episodes = Arc::new(EpisodeStack::new(sender.clone())?);
let shows = Arc::new(ShowStack::new(sender.clone())?);
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?));
stack.add_titled(&episodes.get_stack(), "episodes", "Episodes");
stack.add_titled(&shows.get_stack(), "shows", "Shows");
stack.add_titled(&home.borrow().get_stack(), "home", "Recent");
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
Ok(Content {
stack,
shows,
episodes,
home,
sender,
})
}
pub fn update(&self) {
self.update_episode_view();
self.update_shows_view();
self.update_widget()
self.update_home();
self.update_shows();
}
// 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_home(&self) {
self.home
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update HomeView: {}", err))
.ok();
}
pub fn update_home_if_background(&self) {
if self.stack.get_visible_child_name() != Some("home".into()) {
self.update_home();
}
}
pub fn update_episode_view_if_baground(&self) {
if self.stack.get_visible_child_name() != Some("episodes".into()) {
self.update_episode_view();
}
fn update_shows(&self) {
self.shows
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update ShowsView: {}", err))
.ok();
}
pub fn update_shows_view(&self) {
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);
}
self.shows
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update ShowsView: {}", err))
.ok();
}
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();
}
let pop = self.shows.borrow().populated();
pop.borrow_mut()
.update_widget_if_same(pid)
.map_err(|err| error!("Failed to update ShowsWidget: {}", err))
.ok();
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn get_shows(&self) -> Arc<ShowStack> {
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
self.shows.clone()
}
pub fn get_visible_stack(&self) -> Option<gtk::Stack> {
self.stack.get_visible_child().and_then(|w| w.downcast::<gtk::Stack>().ok())
}
}
-77
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()
}
}
+117
View File
@@ -0,0 +1,117 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::dbqueries::is_episodes_populated;
use hammond_data::errors::DataError;
use app::Action;
use widgets::{EmptyView, HomeView};
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
enum State {
Home,
Empty,
}
#[derive(Debug, Clone)]
pub struct HomeStack {
empty: EmptyView,
episodes: Rc<HomeView>,
stack: gtk::Stack,
state: State,
sender: Sender<Action>,
}
impl HomeStack {
pub fn new(sender: Sender<Action>) -> Result<HomeStack, Error> {
let episodes = HomeView::new(sender.clone())?;
let empty = EmptyView::new();
let stack = gtk::Stack::new();
let state = State::Empty;
stack.add_named(&episodes.container, "home");
stack.add_named(&empty.container, "empty");
let mut home = HomeStack {
empty,
episodes,
stack,
state,
sender,
};
home.determine_state()?;
Ok(home)
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn update(&mut self) -> Result<(), Error> {
// Copy the vertical scrollbar adjustment from the old view.
self.episodes
.save_alignment()
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
.ok();
self.replace_view()?;
// Determine the actuall state.
self.determine_state().map_err(From::from)
}
fn replace_view(&mut self) -> Result<(), Error> {
// Get the container of the view
let old = &self.episodes.container.clone();
let eps = HomeView::new(self.sender.clone())?;
// Remove the old widget and add the new one
// during this the previous view is removed,
// and the visibile child fallsback to empty view.
self.stack.remove(old);
self.stack.add_named(&eps.container, "home");
// Keep the previous state.
let s = self.state;
// Set the visible child back to the previous one to avoid
// the stack transition animation to show the empty view
self.switch_visible(s, StackTransitionType::None);
// replace view in the struct too
self.episodes = eps;
// This might not be needed
old.destroy();
Ok(())
}
fn switch_visible(&mut self, s: State, animation: StackTransitionType) {
use self::State::*;
match s {
Home => {
self.stack.set_visible_child_full("home", animation);
self.state = Home;
}
Empty => {
self.stack.set_visible_child_full("empty", animation);
self.state = Empty;
}
}
}
fn determine_state(&mut self) -> Result<(), DataError> {
if is_episodes_populated()? {
self.switch_visible(State::Home, StackTransitionType::Crossfade);
} else {
self.switch_visible(State::Empty, StackTransitionType::Crossfade);
};
Ok(())
}
}
+5 -3
View File
@@ -1,7 +1,9 @@
mod content;
mod episode;
mod home;
mod populated;
mod show;
pub use self::content::Content;
pub use self::episode::EpisodeStack;
pub use self::show::ShowStack;
pub use self::home::HomeStack;
pub use self::populated::{PopulatedStack, PopulatedState};
pub use self::show::{ShowStack, ShowState};
+162
View File
@@ -0,0 +1,162 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use app::Action;
use widgets::{ShowWidget, ShowsView};
use std::rc::Rc;
use std::sync::Arc;
#[derive(Debug, Clone, Copy)]
pub enum PopulatedState {
View,
Widget,
}
#[derive(Debug, Clone)]
pub struct PopulatedStack {
container: gtk::Box,
populated: Rc<ShowsView>,
show: Rc<ShowWidget>,
stack: gtk::Stack,
state: PopulatedState,
sender: Sender<Action>,
}
impl PopulatedStack {
pub fn new(sender: Sender<Action>) -> Result<PopulatedStack, Error> {
let stack = gtk::Stack::new();
let state = PopulatedState::View;
let populated = ShowsView::new(sender.clone())?;
let show = Rc::new(ShowWidget::default());
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
stack.add_named(&populated.container, "shows");
stack.add_named(&show.container, "widget");
container.add(&stack);
container.show_all();
let show = PopulatedStack {
container,
stack,
populated,
show,
state,
sender,
};
Ok(show)
}
pub fn update(&mut self) {
self.update_widget().map_err(|err| format!("{}", err)).ok();
self.update_shows().map_err(|err| format!("{}", err)).ok();
}
pub fn update_shows(&mut self) -> Result<(), Error> {
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.replace_shows()?;
self.switch_visible(s, StackTransitionType::Crossfade);
Ok(())
}
pub fn replace_shows(&mut self) -> Result<(), Error> {
let old = &self.populated.container.clone();
debug!("Name: {:?}", WidgetExt::get_name(old));
self.populated
.save_alignment()
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
.ok();
let pop = ShowsView::new(self.sender.clone())?;
self.populated = pop;
self.stack.remove(old);
self.stack.add_named(&self.populated.container, "shows");
old.destroy();
Ok(())
}
pub fn replace_widget(&mut self, pd: Arc<Podcast>) -> Result<(), Error> {
let old = self.show.container.clone();
// save the ShowWidget vertical scrollabar alignment
self.show
.podcast_id()
.map(|id| self.show.save_vadjustment(id));
let new = ShowWidget::new(pd, self.sender.clone());
self.show = new;
self.stack.remove(&old);
self.stack.add_named(&self.show.container, "widget");
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::None);
Ok(())
}
pub fn update_widget(&mut self) -> Result<(), Error> {
let old = self.show.container.clone();
let id = self.show.podcast_id();
if id.is_none() {
return Ok(());
}
let pd = dbqueries::get_podcast_from_id(id.unwrap_or_default())?;
self.replace_widget(Arc::new(pd))?;
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::Crossfade);
old.destroy();
Ok(())
}
// Only update widget if its podcast_id is equal to pid.
pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
if self.show.podcast_id() != Some(pid) {
debug!("Different widget. Early return");
return Ok(());
}
self.update_widget()
}
pub fn container(&self) -> gtk::Box {
self.container.clone()
}
pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
use self::PopulatedState::*;
match state {
View => {
self.stack.set_visible_child_full("shows", animation);
self.state = View;
}
Widget => {
self.stack.set_visible_child_full("widget", animation);
self.state = Widget;
}
}
}
}
+67 -153
View File
@@ -1,180 +1,94 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use views::{EmptyView, ShowsPopulated};
use hammond_data::dbqueries::is_podcasts_populated;
use app::Action;
use widgets::ShowWidget;
use stacks::PopulatedStack;
use utils::get_ignored_shows;
use widgets::EmptyView;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
pub enum ShowState {
Populated,
Empty,
}
#[derive(Debug, Clone)]
pub struct ShowStack {
empty: EmptyView,
populated: Rc<RefCell<PopulatedStack>>,
stack: gtk::Stack,
state: ShowState,
sender: Sender<Action>,
}
impl ShowStack {
pub fn new(sender: Sender<Action>) -> Result<ShowStack, Error> {
pub fn new(sender: Sender<Action>) -> Result<Self, Error> {
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())?));
let empty = EmptyView::new();
let stack = gtk::Stack::new();
let state = ShowState::Empty;
let show = ShowStack {
stack.add_named(&populated.borrow().container(), "populated");
stack.add_named(&empty.container, "empty");
let mut show = ShowStack {
empty,
populated,
stack,
sender: sender.clone(),
state,
sender,
};
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")
}
show.determine_state()?;
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()
}
pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
self.populated.clone()
}
pub fn update(&mut self) -> Result<(), Error> {
self.populated.borrow_mut().update();
self.determine_state()
}
fn switch_visible(&mut self, s: ShowState) {
use self::ShowState::*;
match s {
Populated => {
self.stack.set_visible_child_name("populated");
self.state = Populated;
}
Empty => {
self.stack.set_visible_child_name("empty");
self.state = Empty;
}
};
}
fn determine_state(&mut self) -> Result<(), Error> {
use self::ShowState::*;
let ign = get_ignored_shows()?;
debug!("IGNORED SHOWS {:?}", ign);
if is_podcasts_populated(&ign)? {
self.switch_visible(Populated);
} else {
self.switch_visible(Empty);
};
Ok(())
}
}
+4 -2
View File
@@ -2,14 +2,16 @@ 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.
// 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.
// Register the resource so It wont be dropped and will continue to live in
// memory.
resources_register(&resource);
Ok(())
+390 -77
View File
@@ -1,83 +1,232 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use failure::Error;
use gdk::FrameClockExt;
use gdk_pixbuf::Pixbuf;
use glib;
use gtk;
use gtk::prelude::*;
use gtk::{IsA, Widget};
use chrono::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use rayon;
use regex::Regex;
use reqwest;
use send_cell::SendCell;
use serde_json::Value;
// use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries;
use hammond_data::opml;
use hammond_data::pipeline;
use hammond_data::utils::checkup;
use hammond_data::Source;
use hammond_downloader::downloader;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use std::sync::mpsc::Sender;
use std::thread;
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::channel;
use std::sync::{Arc, Mutex, RwLock};
use app::Action;
pub fn refresh_feed_wrapper(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);
/// Lazy evaluates and loads widgets to the parent `container` widget.
///
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
/// will be constructed. An `FnMut` function that returns the desired
/// widget should be passed as the widget `constructor`. You can also specify
/// a `callback` that will be executed when the iteration finish.
///
/// ```no_run
/// # struct Message;
/// # struct MessageWidget(gtk::Label);
///
/// # impl MessageWidget {
/// # fn new(_: Message) -> Self {
/// # MessageWidget(gtk::Label::new("A message"))
/// # }
/// # }
///
/// let messages: Vec<Message> = Vec::new();
/// let list = gtk::ListBox::new();
/// let constructor = |m| MessageWidget::new(m).0;
/// lazy_load(messages, list, constructor, || {});
/// ```
///
/// If you have already constructed the widgets and only want to
/// load them to the parent you can pass a closure that returns it's
/// own argument to the constructor.
///
/// ```no_run
/// # use std::collections::binary_heap::BinaryHeap;
/// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
/// let list = gtk::ListBox::new();
/// lazy_load(widgets, list, |w| w, || {});
/// ```
pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
C: ContainerExt + 'static,
F: FnMut(T::Item) -> W + 'static,
W: IsA<Widget> + WidgetExt,
U: Fn() + 'static,
{
let func = move |x| {
let widget = contructor(x);
container.add(&widget);
widget.show();
};
lazy_load_full(data, func, callback);
}
/// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
/// when the iteration finishes, it executes `finish_callback`.
///
/// This is a more flexible version of `lazy_load` with less constrains.
/// If you just want to lazy add `widgets` to a `container` check if
/// `lazy_load` fits your needs first.
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
F: FnMut(T::Item) + 'static,
U: Fn() + 'static,
{
let mut data = data.into_iter();
gtk::idle_add(move || {
data.next()
.map(|x| func(x))
.map(|_| glib::Continue(true))
.unwrap_or_else(|| {
finish_callback();
glib::Continue(false)
})
});
}
// Kudos to Julian Sparber
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
pub fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::Adjustment) {
if let Some(adj) = view.get_vadjustment() {
if let Some(clock) = view.get_frame_clock() {
let duration = 200;
let start = adj.get_value();
let end = target.get_value();
let start_time = clock.get_frame_time();
let end_time = start_time + 1000 * duration;
view.add_tick_callback(move |_, clock| {
let now = clock.get_frame_time();
// FIXME: `adj.get_value != end` is a float comparison...
if now < end_time && adj.get_value().abs() != end.abs() {
let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
t = ease_out_cubic(t);
adj.set_value(start + t * (end - start));
glib::Continue(true)
} else {
adj.set_value(end);
glib::Continue(false)
}
});
}
}
}
// From clutter-easing.c, based on Robert Penner's
// infamous easing equations, MIT license.
fn ease_out_cubic(t: f64) -> f64 {
let p = t - 1f64;
p * p * p + 1f64
}
lazy_static! {
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
}
pub fn ignore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS
.lock()
.map(|mut guard| guard.insert(id))
.map_err(|err| format_err!("{}", err))
}
pub fn uningore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS
.lock()
.map(|mut guard| guard.remove(&id))
.map_err(|err| format_err!("{}", err))
}
pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
IGNORESHOWS
.lock()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
.map_err(|err| format_err!("{}", err))
}
pub fn cleanup(cleanup_date: DateTime<Utc>) {
checkup(cleanup_date)
.map_err(|err| error!("Check up failed: {}", err))
.ok();
}
pub fn refresh<S>(source: Option<S>, sender: Sender<Action>)
where
S: IntoIterator<Item = Source> + Send + 'static,
{
refresh_feed(source, sender)
.map_err(|err| error!("Failed to update feeds: {}", err))
.ok();
}
/// Update the rss feed(s) originating from `source`.
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
/// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
sender.send(Action::HeaderBarShowUpdateIndicator)?;
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
where
S: IntoIterator<Item = Source> + Send + 'static,
{
sender
.send(Action::HeaderBarShowUpdateIndicator)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
thread::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);
}
}
}
rayon::spawn(move || {
if let Some(s) = source {
// Refresh only specified feeds
pipeline::run(s, false)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Error While trying to update the database."))
.ok();
} else {
// This is what would normally run
if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
}
// Refresh all the feeds
dbqueries::get_sources()
.map(|s| s.into_iter())
.and_then(|s| pipeline::run(s, false))
.map_err(|err| error!("Error: {}", err))
.ok();
};
sender
.send(Action::HeaderBarHideUpdateIndicator)
.expect("Action channel blew up.");
.map_err(|err| error!("Action Sender: {}", err))
.ok();
sender
.send(Action::RefreshAllViews)
.expect("Action channel blew up.");
.map_err(|err| error!("Action Sender: {}", err))
.ok();
});
Ok(())
}
lazy_static! {
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> = {
RwLock::new(HashMap::new())
};
static ref 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,
@@ -87,48 +236,212 @@ lazy_static! {
// 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 get_pixbuf_from_path(pd: &PodcastCoverQuery, size: u32) -> Result<Pixbuf, Error> {
{
let hashmap = CACHED_PIXBUFS
.read()
.map_err(|_| format_err!("Failed to get a lock on the pixbuf cache mutex."))?;
if let Some(px) = hashmap.get(&(pd.id(), size)) {
let m = px.lock()
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
return Ok(m.clone().into_inner());
pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Result<(), Error> {
// Check if there's an active download about this show cover.
// If there is, a callback will be set so this function will be called again.
// If the download succedes, there should be a quick return from the pixbuf cache_image
// If it fails another download will be scheduled.
if let Ok(guard) = COVER_DL_REGISTRY.read() {
if guard.contains(&podcast_id) {
let callback = clone!(image => move || {
let _ = set_image_from_path(&image, podcast_id, size);
glib::Continue(false)
});
gtk::timeout_add(250, callback);
return Ok(());
}
}
let img_path = downloader::cache_image(pd)?;
let px = Pixbuf::new_from_file_at_scale(&img_path, size as i32, size as i32, true)?;
let mut hashmap = CACHED_PIXBUFS
.write()
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
Ok(px)
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
// Check if the requested (cover + size) is already in the chache
// and if so do an early return after that.
if let Some(guard) = hashmap.get(&(podcast_id, size)) {
guard
.lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err))
.and_then(|sendcell| {
sendcell
.try_get()
.map(|px| image.set_from_pixbuf(px))
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
})?;
return Ok(());
}
}
let (sender, receiver) = channel();
THREADPOOL.spawn(move || {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.insert(podcast_id);
}
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(podcast_id) {
sender
.send(downloader::cache_image(&pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&podcast_id);
}
});
let image = image.clone();
let s = size as i32;
gtk::timeout_add(25, move || {
if let Ok(path) = receiver.try_recv() {
if let Ok(path) = path {
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
hashmap.insert((podcast_id, size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px);
}
}
}
glib::Continue(false)
} else {
glib::Continue(true)
}
});
Ok(())
}
// FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>`
pub fn itunes_to_rss(url: &str) -> Result<String, Error> {
let id = itunes_id_from_url(url).ok_or_else(|| format_err!("Failed to find an Itunes ID."))?;
lookup_id(id)
}
fn itunes_id_from_url(url: &str) -> Option<u32> {
lazy_static! {
static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
}
// Get the itunes id from the url
let foo = RE.captures_iter(url).nth(0)?.get(1)?.as_str();
// Parse it to a u32, this *should* never fail
foo.parse::<u32>().ok()
}
fn lookup_id(id: u32) -> Result<String, Error> {
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
let req: Value = reqwest::get(&url)?.json()?;
let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
rssurl()
.map(From::from)
.ok_or_else(|| format_err!("Failed to get url from itunes response"))
}
pub fn on_import_clicked(window: &gtk::Window, sender: &Sender<Action>) {
use glib::translate::ToGlib;
use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType};
// let dialog = FileChooserDialog::new(title, Some(&window), FileChooserAction::Open);
// TODO: It might be better to use a FileChooserNative widget.
// Create the FileChooser Dialog
let dialog = FileChooserNative::new(
Some("Select the file from which to you want to Import Shows."),
Some(window),
FileChooserAction::Open,
Some("_Import"),
None,
);
// Do not show hidden(.thing) files
dialog.set_show_hidden(false);
// Set a filter to show only xml files
let filter = FileFilter::new();
FileFilterExt::set_name(&filter, Some("OPML file"));
filter.add_mime_type("application/xml");
filter.add_mime_type("text/xml");
dialog.add_filter(&filter);
dialog.connect_response(clone!(sender => move |dialog, resp| {
debug!("Dialong Response {}", resp);
if resp == ResponseType::Accept.to_glib() {
// TODO: Show an in-app notifictaion if the file can not be accessed
if let Some(filename) = dialog.get_filename() {
debug!("File selected: {:?}", filename);
rayon::spawn(clone!(sender => move || {
// Parse the file and import the feeds
if let Ok(sources) = opml::import_from_file(filename) {
// Refresh the succesfully parsed feeds to index them
refresh(Some(sources), sender)
} else {
let text = String::from("Failed to parse the Imported file");
sender.send(Action::ErrorNotification(text))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
}))
} else {
let text = String::from("Selected File could not be accessed.");
sender.send(Action::ErrorNotification(text))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
}
dialog.destroy();
}));
dialog.run();
}
#[cfg(test)]
mod tests {
use super::*;
use hammond_data::Source;
use hammond_data::dbqueries;
// use hammond_data::Source;
// use hammond_data::dbqueries;
#[test]
// #[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
#[ignore]
fn test_get_pixbuf_from_path() {
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// #[ignore]
// Disabled till https://gitlab.gnome.org/World/hammond/issues/56
// fn test_set_image_from_path() {
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
let source = Source::from_url(url).unwrap();
// let source = Source::from_url(url).unwrap();
// Copy it's id
let sid = source.id();
pipeline::run(vec![source], true).unwrap();
// let sid = source.id();
// pipeline::run(vec![source], true).unwrap();
// Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let pxbuf = get_pixbuf_from_path(&pd.into(), 256);
assert!(pxbuf.is_ok());
// 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());
}
}
-7
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;
-142
View File
@@ -1,142 +0,0 @@
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use app::Action;
use utils::get_pixbuf_from_path;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct ShowsPopulated {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsPopulated {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsPopulated {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsPopulated {
pub fn new(sender: Sender<Action>) -> Result<ShowsPopulated, Error> {
let pop = ShowsPopulated::default();
pop.init(sender)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) -> Result<(), Error> {
self.flowbox.connect_child_activated(move |_, child| {
if let Err(err) = on_child_activate(child, sender.clone()) {
error!(
"Something went wrong during flowbox child activation: {}.",
err
)
};
});
// Populate the flowbox with the Podcasts.
self.populate_flowbox()
}
fn populate_flowbox(&self) -> Result<(), Error> {
let podcasts = dbqueries::get_podcasts()?;
podcasts
.into_iter()
.map(|pd| Arc::new(pd))
.for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
Ok(())
}
pub fn is_empty(&self) -> bool {
self.flowbox.get_children().is_empty()
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &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: Arc<Podcast>) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: Arc<Podcast>) {
self.container.set_tooltip_text(pd.title());
if let Err(err) = self.set_cover(pd.clone()) {
error!("Failed to set a cover: {}", err)
}
WidgetExt::set_name(&self.child, &pd.id().to_string());
}
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
let image = get_pixbuf_from_path(&pd.clone().into(), 256)?;
self.cover.set_from_pixbuf(&image);
Ok(())
}
}
+42
View File
@@ -0,0 +1,42 @@
use gtk;
use gtk::prelude::*;
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
/// Given a `window` create and attach an `gtk::AboutDialog` to it.
pub fn about_dialog(window: &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",
"Zander Brown",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.3.4");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
// dialog.set_website_label("Learn more about Hammond");
dialog.set_transient_for(window);
dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors);
dialog.connect_response(|dlg, _| dlg.destroy());
dialog.show();
}
+94
View File
@@ -0,0 +1,94 @@
use glib;
use gtk;
use gtk::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
pub enum UndoState {
Shown,
Hidden,
}
#[derive(Debug, Clone)]
pub struct InAppNotification {
revealer: gtk::Revealer,
text: gtk::Label,
undo: gtk::Button,
close: gtk::Button,
}
impl Default for InAppNotification {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/inapp_notif.ui");
let revealer: gtk::Revealer = builder.get_object("revealer").unwrap();
let text: gtk::Label = builder.get_object("text").unwrap();
let undo: gtk::Button = builder.get_object("undo").unwrap();
let close: gtk::Button = builder.get_object("close").unwrap();
InAppNotification {
revealer,
text,
undo,
close,
}
}
}
impl InAppNotification {
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self
where
F: FnMut() -> glib::Continue + 'static,
U: Fn() + 'static,
{
let notif = InAppNotification::default();
notif.text.set_text(&text);
let revealer = notif.revealer.clone();
let id = timeout_add_seconds(6, move || {
revealer.set_reveal_child(false);
callback()
});
let id = Rc::new(RefCell::new(Some(id)));
// Cancel the callback
let revealer = notif.revealer.clone();
notif.undo.connect_clicked(move |_| {
let foo = id.borrow_mut().take();
if let Some(id) = foo {
glib::source::source_remove(id);
}
undo_callback();
// Hide the notification
revealer.set_reveal_child(false);
});
// Hide the revealer when the close button is clicked
let revealer = notif.revealer.clone();
notif.close.connect_clicked(move |_| {
revealer.set_reveal_child(false);
});
match show_undo {
UndoState::Shown => (),
UndoState::Hidden => notif.undo.hide(),
}
notif
}
// This is a seperate method cause in order to get a nice animation
// the revealer should be attached to something that displays it.
// Previously we where doing it in the constructor, which had the result
// of the animation being skipped cause there was no parent widget to display it.
pub fn show(&self, overlay: &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);
}
}
@@ -7,7 +7,7 @@ pub struct EmptyView {
impl Default for EmptyView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_view.ui");
let view: gtk::Box = builder.get_object("empty_view").unwrap();
EmptyView { container: view }
+412 -251
View File
@@ -1,24 +1,24 @@
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use chrono::Duration;
use chrono;
use chrono::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use humansize::{file_size_opts as size_opts, FileSize};
use open;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries;
use hammond_data::utils::get_download_folder;
use hammond_data::EpisodeWidgetQuery;
use app::Action;
use manager;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::Sender;
use std::rc::Rc;
use std::sync::{Arc, Mutex, TryLockError};
lazy_static! {
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
@@ -36,266 +36,452 @@ lazy_static! {
allow_negative: false,
})
};
static ref NOW: DateTime<Utc> = Utc::now();
}
#[derive(Debug, Clone)]
#[derive(Clone, Debug)]
pub struct EpisodeWidget {
pub container: gtk::Box,
info: InfoLabels,
buttons: Buttons,
progressbar: gtk::ProgressBar,
}
#[derive(Clone, Debug)]
struct InfoLabels {
container: gtk::Box,
title: gtk::Label,
date: gtk::Label,
separator1: gtk::Label,
duration: gtk::Label,
separator2: gtk::Label,
local_size: gtk::Label,
size_separator: gtk::Label,
total_size: gtk::Label,
}
#[derive(Clone, Debug)]
struct Buttons {
container: gtk::ButtonBox,
play: gtk::Button,
download: gtk::Button,
cancel: gtk::Button,
title: gtk::Label,
date: gtk::Label,
duration: gtk::Label,
progress: gtk::ProgressBar,
total_size: gtk::Label,
local_size: gtk::Label,
separator1: gtk::Label,
separator2: gtk::Label,
prog_separator: gtk::Label,
desc: gtk::Button,
}
impl InfoLabels {
fn init(&self, episode: &EpisodeWidgetQuery) {
// Set the title label state.
self.set_title(episode);
// Set the date label.
self.set_date(episode.epoch());
// Set the duaration label.
self.set_duration(episode.duration());
// Set the total_size label.
self.set_size(episode.length())
}
fn set_title(&self, episode: &EpisodeWidgetQuery) {
self.title.set_text(episode.title());
if episode.played().is_some() {
self.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
} else {
self.title
.get_style_context()
.map(|c| c.remove_class("dim-label"));
}
}
// Set the date label of the episode widget.
fn set_date(&self, epoch: i32) {
lazy_static! {
static ref NOW: DateTime<Utc> = Utc::now();
};
let ts = Utc.timestamp(i64::from(epoch), 0);
// If the episode is from a different year, print year as well
if NOW.year() != ts.year() {
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
// Else omit the year from the label
} else {
self.date.set_text(ts.format("%e %b").to_string().trim());
}
}
// Set the duration label of the episode widget.
fn set_duration(&self, seconds: Option<i32>) {
// If lenght is provided
if let Some(s) = seconds {
// Convert seconds to minutes
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
// If the lenght is 1 or more minutes
if minutes != 0 {
// Set the label and show them.
self.duration.set_text(&format!("{} min", minutes));
self.duration.show();
self.separator1.show();
return;
}
}
// Else hide the labels
self.separator1.hide();
self.duration.hide();
}
// Set the size label of the episode widget.
fn set_size(&self, bytes: Option<i32>) {
// Convert the bytes to a String label
let size = || -> Option<String> {
let s = bytes?;
if s == 0 {
return None;
}
s.file_size(SIZE_OPTS.clone()).ok()
};
if let Some(s) = size() {
self.total_size.set_text(&s);
self.total_size.show();
self.separator2.show();
} else {
self.total_size.hide();
self.separator2.hide();
}
}
}
impl Default for EpisodeWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episode_widget.ui");
let container: gtk::Box = builder.get_object("episode_container").unwrap();
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
let container = builder.get_object("episode_container").unwrap();
let 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 buttons_container = builder.get_object("button_box").unwrap();
let download = builder.get_object("download_button").unwrap();
let play = builder.get_object("play_button").unwrap();
let cancel = builder.get_object("cancel_button").unwrap();
let desc_button = builder.get_object("desc_button").unwrap();
let title: gtk::Label = builder.get_object("title_label").unwrap();
let 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 info_container = builder.get_object("info_container").unwrap();
let title = builder.get_object("title_label").unwrap();
let date = builder.get_object("date_label").unwrap();
let duration = builder.get_object("duration_label").unwrap();
let local_size = builder.get_object("local_size").unwrap();
let total_size = builder.get_object("total_size").unwrap();
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
let separator1 = builder.get_object("separator1").unwrap();
let separator2 = builder.get_object("separator2").unwrap();
let size_separator = builder.get_object("prog_separator").unwrap();
EpisodeWidget {
container,
progress,
download,
play,
cancel,
info: InfoLabels {
container: info_container,
title,
duration,
date,
total_size,
local_size,
separator1,
duration,
separator2,
prog_separator,
local_size,
total_size,
size_separator,
},
buttons: Buttons {
container: buttons_container,
play,
download,
cancel,
desc: desc_button,
},
progressbar,
container,
}
}
}
impl EpisodeWidget {
pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let widget = EpisodeWidget::default();
widget.init(episode, sender);
pub fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Rc<Self> {
let widget = Rc::new(Self::default());
widget.info.init(episode);
Self::determine_buttons_state(&widget, episode, sender)
.map_err(|err| error!("Error: {}", err))
.ok();
widget
.on_desc_clicked(episode, sender)
.map_err(|err| error!("Error: {}", err))
.ok();
widget
}
fn init(&self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
// Set the title label state.
self.set_title(&episode);
// Set the duaration label.
self.set_duration(episode.duration());
// Set the date label.
self.set_date(episode.epoch());
// Show or hide the play/delete/download buttons upon widget initialization.
if let Err(err) = self.show_buttons(episode.local_uri()) {
debug!("Failed to determine play/download button state.");
debug!("Error: {}", err);
}
// Set the size label.
if let Err(err) = self.set_total_size(episode.length()) {
error!("Failed to set the Size label.");
error!("Error: {}", err);
}
// Determine what the state of the progress bar should be.
if let Err(err) = self.determine_progess_bar() {
error!("Something went wrong determining the ProgressBar State.");
error!("Error: {}", err);
}
let episode = Arc::new(Mutex::new(episode));
let title = self.title.clone();
self.play
.connect_clicked(clone!(episode, sender => move |_| {
if let Ok(mut ep) = episode.lock() {
if let Err(err) = on_play_bttn_clicked(&mut ep, &title, sender.clone()){
error!("Error: {}", err);
};
}
fn on_desc_clicked(
&self,
episode: &EpisodeWidgetQuery,
sender: &Sender<Action>,
) -> Result<(), Error> {
let id = episode.rowid();
if dbqueries::episode_had_description(id)? {
self.buttons.desc.show();
self.buttons
.desc
.connect_clicked(clone!(sender => move |_| {
sender.send(Action::AboutWidget(id))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}));
self.download
.connect_clicked(clone!(episode, sender => move |dl| {
dl.set_sensitive(false);
if let Ok(ep) = episode.lock() {
if let Err(err) = on_download_clicked(&ep, sender.clone()) {
error!("Download failed to start.");
error!("Error: {}", err);
Ok(())
} else {
info!("Donwload started succesfully.");
}
}
}));
}
/// Show or hide the play/delete/download buttons upon widget initialization.
fn show_buttons(&self, local_uri: Option<&str>) -> Result<(), Error> {
let path = local_uri.ok_or_else(|| format_err!("Path is None"))?;
if Path::new(path).exists() {
self.download.hide();
self.play.show();
}
self.buttons.desc.hide();
Ok(())
}
/// Determine the title state.
fn set_title(&self, episode: &EpisodeWidgetQuery) {
self.title.set_text(episode.title());
// Grey out the title if the episode is played.
if episode.played().is_some() {
self.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
}
}
/// Set the date label depending on the current time.
fn set_date(&self, epoch: i32) {
let date = Utc.timestamp(i64::from(epoch), 0);
if NOW.year() == date.year() {
self.date.set_text(date.format("%e %b").to_string().trim());
} else {
self.date
.set_text(date.format("%e %b %Y").to_string().trim());
};
// fn init(widget: Rc<Self>, sender: &Sender<Action>) {}
// InProgress State:
// * Show ProgressBar and Cancel Button.
// * Show `total_size`, `local_size` labels and `size_separator`.
// * Hide Download and Play Buttons
fn state_prog(&self) {
self.progressbar.show();
self.buttons.cancel.show();
self.info.total_size.show();
self.info.local_size.show();
self.info.size_separator.show();
self.buttons.play.hide();
self.buttons.download.hide();
}
/// Set the duration label.
fn set_duration(&self, seconds: Option<i32>) -> Option<()> {
let minutes = Duration::seconds(seconds?.into()).num_minutes();
if minutes == 0 {
return None;
// Playable State:
// * Hide ProgressBar and Cancel, Download Buttons.
// * Hide `local_size` labels and `size_separator`.
// * Show Play Button and `total_size` label
fn state_playable(&self) {
self.progressbar.hide();
self.buttons.cancel.hide();
self.buttons.download.hide();
self.info.local_size.hide();
self.info.size_separator.hide();
self.info.total_size.show();
self.buttons.play.show();
}
self.duration.set_text(&format!("{} min", minutes));
self.duration.show();
self.separator1.show();
Some(())
// ToDownload State:
// * Hide ProgressBar and Cancel, Play Buttons.
// * Hide `local_size` labels and `size_separator`.
// * Show Download Button
// * Determine `total_size` label state (Comes from `episode.lenght`).
fn state_download(&self) {
self.progressbar.hide();
self.buttons.cancel.hide();
self.buttons.play.hide();
self.info.local_size.hide();
self.info.size_separator.hide();
self.buttons.download.show();
}
/// Set the Episode label dependings on its size
fn set_total_size(&self, bytes: Option<i32>) -> Result<(), Error> {
let size = bytes.ok_or_else(|| format_err!("Size is None."))?;
if size == 0 {
bail!("Size is 0.");
fn update_progress(&self, local_size: &str, fraction: f64) {
self.info.local_size.set_text(local_size);
self.progressbar.set_fraction(fraction);
}
let s = size.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))?;
self.total_size.set_text(&s);
self.total_size.show();
self.separator2.show();
Ok(())
}
/// Change the state of the `EpisodeWidget`.
///
/// Function Flowchart:
///
/// ------------------- --------------
/// | Is the Episode | YES | State: |
/// | currently being | ----> | InProgress |
/// | downloaded? | | |
/// ------------------- --------------
/// |
/// | NO
/// |
/// \_/
/// ------------------- --------------
/// | is the episode | YES | State: |
/// | downloaded | ----> | Playable |
/// | already? | | |
/// ------------------- --------------
/// |
/// | NO
/// |
/// \_/
/// -------------------
/// | State: |
/// | ToDownload |
/// -------------------
fn determine_buttons_state(
widget: &Rc<Self>,
episode: &EpisodeWidgetQuery,
sender: &Sender<Action>,
) -> Result<(), Error> {
// Reset the buttons state no matter the glade file.
// This is just to make it easier to port to relm in the future.
widget.buttons.cancel.hide();
widget.buttons.play.hide();
widget.buttons.download.hide();
// FIXME: REFACTOR ME
// Something Something State-Machine?
fn determine_progess_bar(&self) -> 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> {
// Check if the episode is being downloaded
let id = episode.rowid();
let active_dl = move || -> 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())
}()?;
};
if let Some(prog) = active_dl {
// FIXME: Document me?
self.download.hide();
self.progress.show();
self.local_size.show();
self.total_size.show();
self.separator2.show();
self.prog_separator.show();
self.cancel.show();
// State: InProggress
if let Some(prog) = active_dl()? {
// set a callback that will update the state when the download finishes
let callback = clone!(widget, sender => move || {
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() {
if !guard.contains_key(&id) {
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
Self::determine_buttons_state(&widget, &ep, &sender)
.map_err(|err| error!("Error: {}", err))
.ok();
let progress_bar = self.progress.clone();
let total_size = self.total_size.clone();
let local_size = self.local_size.clone();
return glib::Continue(false)
}
}
}
// Setup a callback that will update the progress bar.
update_progressbar_callback(prog.clone(), id, &progress_bar, &local_size);
glib::Continue(true)
});
gtk::timeout_add(250, callback);
// Wire the cancel button
widget
.buttons
.cancel
.connect_clicked(clone!(prog, widget, sender => move |_| {
// Cancel the download
if let Ok(mut m) = prog.lock() {
m.cancel();
}
// Cancel is not instant so we have to wait a bit
timeout_add(50, clone!(widget, sender => move || {
if let Ok(thing) = active_dl() {
if thing.is_none() {
// Recalculate the widget state
dbqueries::get_episode_widget_from_rowid(id)
.map_err(From::from)
.and_then(|ep| Self::determine_buttons_state(&widget, &ep, &sender))
.map_err(|err| error!("Error: {}", err))
.ok();
return glib::Continue(false)
}
}
glib::Continue(true)
}));
}));
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), &total_size);
update_total_size_callback(&widget, &prog);
self.cancel.connect_clicked(clone!(prog => move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
// Setup a callback that will update the progress bar.
update_progressbar_callback(&widget, &prog, id);
// Change the widget layout/state
widget.state_prog();
return Ok(());
}
// State: Playable
if episode.local_uri().is_some() {
// Change the widget layout/state
widget.state_playable();
// Wire the play button
widget
.buttons
.play
.connect_clicked(clone!(widget, sender => move |_| {
if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_play_bttn_clicked(&widget, &mut ep, &sender)
.map_err(|err| error!("Error: {}", err))
.ok();
}
}));
return Ok(());
}
// State: ToDownload
// Wire the download button
widget
.buttons
.download
.connect_clicked(clone!(widget, sender => move |dl| {
// Make the button insensitive so it won't be pressed twice
dl.set_sensitive(false);
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_download_clicked(&ep, &sender)
.and_then(|_| {
info!("Donwload started succesfully.");
Self::determine_buttons_state(&widget, &ep, &sender)
})
.map_err(|err| error!("Error: {}", err))
.ok();
}
// Restore sensitivity after operations above complete
dl.set_sensitive(true);
}));
// Change the widget state into `ToDownload`
widget.state_download();
Ok(())
}
}
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
let download_fold = get_download_folder(&pd.title().to_owned())?;
let download_fold = get_download_folder(&pd.title())?;
// Start a new download.
manager::add(ep.rowid(), &download_fold, sender.clone())?;
manager::add(ep.rowid(), download_fold)?;
// Update Views
sender.send(Action::RefreshEpisodesView)?;
sender.send(Action::RefreshWidgetIfVis)?;
Ok(())
sender
.send(Action::RefreshEpisodesViewBGR)
.map_err(From::from)
}
fn on_play_bttn_clicked(
widget: &Rc<EpisodeWidget>,
episode: &mut EpisodeWidgetQuery,
title: &gtk::Label,
sender: Sender<Action>,
sender: &Sender<Action>,
) -> Result<(), Error> {
open_uri(episode.rowid())?;
episode.set_played_now()?;
if episode.set_played_now().is_ok() {
title.get_style_context().map(|c| c.add_class("dim-label"));
sender.send(Action::RefreshEpisodesViewBGR)?;
};
Ok(())
widget.info.set_title(&episode);
sender
.send(Action::RefreshEpisodesViewBGR)
.map_err(From::from)
}
fn open_uri(rowid: i32) -> Result<(), Error> {
@@ -313,55 +499,50 @@ fn open_uri(rowid: i32) -> Result<(), Error> {
}
// Setup a callback that will update the progress bar.
#[inline]
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
fn update_progressbar_callback(
prog: Arc<Mutex<manager::Progress>>,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32,
progress_bar: &gtk::ProgressBar,
local_size: &gtk::Label,
) {
timeout_add(
400,
clone!(prog, progress_bar, progress_bar, local_size=> move || {
progress_bar_helper(prog.clone(), episode_rowid, &progress_bar, &local_size)
let callback = clone!(widget, prog => move || {
progress_bar_helper(&widget, &prog, episode_rowid)
.unwrap_or(glib::Continue(false))
}),
);
});
timeout_add(100, callback);
}
#[allow(if_same_then_else)]
fn progress_bar_helper(
prog: Arc<Mutex<manager::Progress>>,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32,
progress_bar: &gtk::ProgressBar,
local_size: &gtk::Label,
) -> 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())
let (fraction, downloaded) = match prog.try_lock() {
Ok(guard) => (guard.get_fraction(), guard.get_downloaded()),
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
};
// Update local_size label
downloaded
.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))
.map(|x| local_size.set_text(&x))?;
// I hate floating points.
// Update the progress_bar.
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
progress_bar.set_fraction(fraction);
// Update local_size label
let size = downloaded
.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))?;
widget.update_progress(&size, fraction);
}
// info!("Fraction: {}", progress_bar.get_fraction());
// info!("Fraction: {}", 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)
let active = match manager::ACTIVE_DOWNLOADS.read() {
Ok(guard) => guard.contains_key(&episode_rowid),
Err(_) => return Err(format_err!("Failed to get a lock on the mutex.")),
};
if (fraction >= 1.0) && (!fraction.is_nan()) {
@@ -376,33 +557,30 @@ fn progress_bar_helper(
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
fn update_total_size_callback(prog: Arc<Mutex<manager::Progress>>, total_size: &gtk::Label) {
timeout_add(
500,
clone!(prog, total_size => move || {
total_size_helper(prog.clone(), &total_size).unwrap_or(glib::Continue(true))
}),
);
#[inline]
fn update_total_size_callback(widget: &Rc<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) {
let callback = clone!(prog, widget => move || {
total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true))
});
timeout_add(100, callback);
}
fn total_size_helper(
prog: Arc<Mutex<manager::Progress>>,
total_size: &gtk::Label,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
) -> 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()
let total_bytes = match prog.try_lock() {
Ok(guard) => guard.get_total_size(),
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
};
debug!("Total Size: {}", total_bytes);
if total_bytes != 0 {
// Update the total_size label
total_bytes
.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))
.map(|x| total_size.set_text(&x))?;
widget.info.set_size(Some(total_bytes as i32));
// Do not call again the callback
Ok(glib::Continue(false))
} else {
@@ -414,20 +592,3 @@ fn total_size_helper(
// 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();
episodes.into_iter().for_each(|ep| {
let widget = EpisodeWidget::new(ep, sender.clone());
list.add(&widget.container);
});
list.set_vexpand(false);
list.set_hexpand(false);
list.set_visible(true);
list.set_selection_mode(gtk::SelectionMode::None);
Ok(list)
}
@@ -1,16 +1,25 @@
use chrono::prelude::*;
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::EpisodeWidgetQuery;
use crossbeam_channel::Sender;
use hammond_data::dbqueries;
use hammond_data::EpisodeWidgetQuery;
use send_cell::SendCell;
use app::Action;
use utils::get_pixbuf_from_path;
use utils::{self, lazy_load_full};
use widgets::EpisodeWidget;
use std::sync::mpsc::Sender;
use std::rc::Rc;
use std::sync::Mutex;
lazy_static! {
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
Mutex::new(None);
}
#[derive(Debug, Clone)]
enum ListSplit {
@@ -22,7 +31,7 @@ enum ListSplit {
}
#[derive(Debug, Clone)]
pub struct EpisodesView {
pub struct HomeView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box,
@@ -38,9 +47,9 @@ pub struct EpisodesView {
rest_list: gtk::ListBox,
}
impl Default for EpisodesView {
impl Default for HomeView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
@@ -55,7 +64,7 @@ impl Default for EpisodesView {
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
EpisodesView {
HomeView {
container,
scrolled_window,
frame_parent,
@@ -74,88 +83,76 @@ impl Default for EpisodesView {
}
// TODO: REFACTOR ME
impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
let view = EpisodesView::default();
let episodes = dbqueries::get_episodes_widgets_with_limit(50)?;
impl HomeView {
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
use self::ListSplit::*;
let view = Rc::new(HomeView::default());
let ignore = utils::get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
let now_utc = Utc::now();
episodes.into_iter().for_each(|ep| {
let view_ = view.clone();
let func = move |ep: EpisodeWidgetQuery| {
let epoch = ep.epoch();
let viewep = EpisodesViewWidget::new(ep, sender.clone());
let widget = HomeEpisode::new(&ep, &sender);
let t = split(&now_utc, i64::from(epoch));
match t {
ListSplit::Today => {
view.today_list.add(&viewep.container);
match split(&now_utc, i64::from(epoch)) {
Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
}
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();
}
let view_ = view.clone();
let callback = move || {
view_
.set_vadjustment()
.map_err(|err| format!("{}", err))
.ok();
};
lazy_load_full(episodes, func, callback);
view.container.show_all();
Ok(view)
}
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 set_vadjustment(&self) -> Result<(), Error> {
let guard = EPISODES_VIEW_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = EPISODES_VIEW_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn add_to_box(widget: &HomeEpisode, listbox: &gtk::ListBox, box_: &gtk::Box) {
listbox.add(&widget.container);
box_.show();
}
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
@@ -175,22 +172,23 @@ fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
}
#[derive(Debug, Clone)]
struct EpisodesViewWidget {
struct HomeEpisode {
container: gtk::Box,
image: gtk::Image,
// FIXME: Change it to `EpisodeWidget` instead of a `Box`?
episode: gtk::Box,
}
impl Default for EpisodesViewWidget {
impl Default for HomeEpisode {
fn default() -> Self {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 6);
EpisodesViewWidget {
HomeEpisode {
container,
image,
episode: ep.container,
@@ -198,19 +196,19 @@ impl Default for EpisodesViewWidget {
}
}
impl EpisodesViewWidget {
fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
impl HomeEpisode {
fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> HomeEpisode {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
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 ep = EpisodeWidget::new(episode, sender);
let view = EpisodesViewWidget {
let view = HomeEpisode {
container,
image,
episode: ep.container,
episode: ep.container.clone(),
};
view.init(pid);
@@ -218,17 +216,14 @@ impl EpisodesViewWidget {
}
fn init(&self, podcast_id: i32) {
if let Err(err) = self.set_cover(podcast_id) {
error!("Failed to set a cover: {}", err)
}
self.set_cover(podcast_id)
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
self.container.pack_start(&self.episode, true, true, 6);
}
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
let pd = dbqueries::get_podcast_cover_from_id(podcast_id)?;
let img = get_pixbuf_from_path(&pd, 64)?;
self.image.set_from_pixbuf(&img);
Ok(())
utils::set_image_from_path(&self.image, podcast_id, 64)
}
}
+11 -1
View File
@@ -1,5 +1,15 @@
mod show;
mod aboutdialog;
pub mod appnotif;
mod empty;
mod episode;
mod home_view;
mod show;
mod shows_view;
pub use self::aboutdialog::about_dialog;
pub use self::empty::EmptyView;
pub use self::episode::EpisodeWidget;
pub use self::home_view::HomeView;
pub use self::show::ShowWidget;
pub use self::show::{mark_all_notif, remove_show_notif};
pub use self::shows_view::ShowsView;
+256 -58
View File
@@ -1,20 +1,30 @@
use dissolve;
use failure::Error;
use glib;
use gtk;
use gtk::prelude::*;
use open;
use hammond_data::Podcast;
use crossbeam_channel::{SendError, Sender};
use failure::Error;
use html2pango::markup_from_raw;
use open;
use rayon;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::utils::{delete_show, replace_extra_spaces};
use hammond_data::utils::delete_show;
use hammond_data::Podcast;
use app::Action;
use utils::get_pixbuf_from_path;
use widgets::episode::episodes_listbox;
use utils::{self, lazy_load};
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::thread;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
lazy_static! {
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
Mutex::new(None);
}
#[derive(Debug, Clone)]
pub struct ShowWidget {
@@ -25,15 +35,16 @@ pub struct ShowWidget {
link: gtk::Button,
settings: gtk::MenuButton,
unsub: gtk::Button,
episodes: gtk::Frame,
episodes: gtk::ListBox,
podcast_id: Option<i32>,
}
impl Default for ShowWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
let 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 episodes = builder.get_object("episodes").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap();
let description: gtk::Label = builder.get_object("description").unwrap();
@@ -50,100 +61,287 @@ impl Default for ShowWidget {
link,
settings,
episodes,
podcast_id: None,
}
}
}
impl ShowWidget {
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> ShowWidget {
let pdw = ShowWidget::default();
pdw.init(pd, sender);
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> Rc<ShowWidget> {
let mut pdw = ShowWidget::default();
pdw.init(&pd, &sender);
let pdw = Rc::new(pdw);
populate_listbox(&pdw, pd, sender)
.map_err(|err| error!("Failed to populate the listbox: {}", err))
.ok();
pdw
}
pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
WidgetExt::set_name(&self.container, &pd.id().to_string());
pub fn init(&mut self, pd: &Arc<Podcast>, sender: &Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| {
if let Err(err) = on_unsub_button_clicked(pd.clone(), bttn, sender.clone()) {
error!("Error: {}", err);
}
on_unsub_button_clicked(pd.clone(), bttn, &sender);
}));
self.setup_listbox(&pd, sender.clone());
self.set_description(pd.description());
self.podcast_id = Some(pd.id());
if let Err(err) = self.set_cover(pd.clone()) {
error!("Failed to set a cover: {}", err)
}
self.set_cover(&pd)
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
let link = pd.link().to_owned();
self.link.set_tooltip_text(Some(link.as_str()));
self.link.connect_clicked(move |_| {
info!("Opening link: {}", &link);
if let Err(err) = open::that(&link) {
error!("Failed to open link: {}", &link);
error!("Error: {}", err);
}
open::that(&link)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed open link: {}", &link))
.ok();
});
}
/// 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));
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
let episodes = self.episodes.clone();
mark_all.connect_clicked(clone!(pd, sender => move |_| {
on_played_button_clicked(
pd.clone(),
&episodes,
&sender
)
}));
self.settings.set_popover(&show_menu);
}
/// Set the show cover.
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
let image = get_pixbuf_from_path(&pd.into(), 128)?;
self.cover.set_from_pixbuf(&image);
Ok(())
fn set_cover(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
utils::set_image_from_path(&self.cover, pd.id(), 256)
}
/// Set the descripton text.
fn set_description(&self, text: &str) {
// TODO: Temporary solution until we render html urls/bold/italic probably with markup.
let desc = dissolve::strip_html_tags(text).join(" ");
self.description.set_text(&replace_extra_spaces(&desc));
self.description.set_markup(&markup_from_raw(text));
}
/// Save the scrollabar vajustment to the cache.
pub fn save_vadjustment(&self, oldid: i32) -> Result<(), Error> {
if let Ok(mut guard) = SHOW_WIDGET_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some((oldid, SendCell::new(adj)));
debug!("Widget Alignment was saved with ID: {}.", oldid);
}
Ok(())
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
fn set_vadjustment(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
let guard = SHOW_WIDGET_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some((oldid, ref sendcell)) = *guard {
// Only copy the old scrollbar if both widget's represent the same podcast.
debug!("PID: {}", pd.id());
debug!("OLDID: {}", oldid);
if pd.id() != oldid {
debug!("Early return");
return Ok(());
};
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
pub fn podcast_id(&self) -> Option<i32> {
self.podcast_id
}
}
fn on_unsub_button_clicked(
/// Populate the listbox with the shows episodes.
fn populate_listbox(
show: &Rc<ShowWidget>,
pd: Arc<Podcast>,
unsub_button: &gtk::Button,
sender: Sender<Action>,
) -> Result<(), Error> {
use crossbeam_channel::bounded;
use crossbeam_channel::TryRecvError::*;
let count = dbqueries::get_pd_episodes_count(&pd)?;
let (sender_, receiver) = bounded(1);
rayon::spawn(clone!(pd => move || {
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
// The receiver can be dropped if there's an early return
// like on show without episodes for example.
sender_.send(episodes).ok();
}));
if count == 0 {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
show.episodes.add(&container);
return Ok(());
}
let show_ = show.clone();
gtk::idle_add(move || {
let episodes = match receiver.try_recv() {
Ok(e) => e,
Err(Empty) => return glib::Continue(true),
Err(Disconnected) => return glib::Continue(false),
};
let list = show_.episodes.clone();
let constructor = clone!(sender => move |ep| {
EpisodeWidget::new(&ep, &sender).container.clone()
});
let callback = clone!(pd, show_ => move || {
show_.set_vadjustment(&pd)
.map_err(|err| error!("Failed to set ShowWidget Alignment: {}", err))
.ok();
});
lazy_load(episodes, list.clone(), constructor, callback);
glib::Continue(false)
});
Ok(())
}
fn on_unsub_button_clicked(pd: Arc<Podcast>, unsub_button: &gtk::Button, sender: &Sender<Action>) {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.hide();
// Spawn a thread so it won't block the ui.
thread::spawn(move || {
if let Err(err) = delete_show(&pd) {
error!("Something went wrong trying to remove {}", pd.title());
error!("Error: {}", err);
}
});
unsub_button.set_sensitive(false);
let wrap = || -> Result<(), SendError<_>> {
sender.send(Action::RemoveShow(pd))?;
sender.send(Action::HeaderBarNormal)?;
sender.send(Action::ShowShowsAnimated)?;
// Queue a refresh after the switch to avoid blocking the db.
sender.send(Action::RefreshShowsView)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
};
wrap().map_err(|err| error!("Action Sender: {}", err)).ok();
unsub_button.set_sensitive(true);
}
#[allow(dead_code)]
fn on_played_button_clicked(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
fn on_played_button_clicked(pd: Arc<Podcast>, episodes: &gtk::ListBox, sender: &Sender<Action>) {
if dim_titles(episodes).is_none() {
error!("Something went horribly wrong when dimming the titles.");
warn!("RUN WHILE YOU STILL CAN!");
}
sender
.send(Action::MarkAllPlayerNotification(pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
fn mark_all_watched(pd: &Podcast, sender: &Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?;
sender.send(Action::RefreshWidget)?;
// Not all widgets migth have been loaded when the mark_all is hit
// So we will need to refresh again after it's done.
sender.send(Action::RefreshWidgetIfSame(pd.id()))?;
sender.send(Action::RefreshEpisodesView).map_err(From::from)
}
pub fn mark_all_notif(pd: Arc<Podcast>, sender: &Sender<Action>) -> InAppNotification {
let id = pd.id();
let callback = clone!(sender => move || {
mark_all_watched(&pd, &sender)
.map_err(|err| error!("Notif Callback Error: {}", err))
.ok();
glib::Continue(false)
});
let undo_callback = clone!(sender => move || {
sender.send(Action::RefreshWidgetIfSame(id))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
});
let text = "Marked all episodes as listened";
InAppNotification::new(text, callback, undo_callback, UndoState::Shown)
}
pub fn remove_show_notif(pd: Arc<Podcast>, sender: Sender<Action>) -> InAppNotification {
let text = format!("Unsubscribed from {}", pd.title());
utils::ignore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not insert {} to the ignore list.", pd.title()))
.ok();
let callback = clone!(pd, sender => move || {
utils::uningore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not remove {} from the ignore list.", pd.title()))
.ok();
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd, sender => move || {
delete_show(&pd)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete {}", pd.title()))
.ok();
sender.send(Action::RefreshEpisodesView).ok();
}));
glib::Continue(false)
});
let undo_wrap = move || -> Result<(), Error> {
utils::uningore_show(pd.id())?;
sender.send(Action::RefreshShowsView)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
};
let undo_callback = move || {
undo_wrap().map_err(|err| error!("{}", err)).ok();
};
InAppNotification::new(&text, callback, undo_callback, UndoState::Shown)
}
// Ideally if we had a custom widget this would have been as simple as:
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
// But now I can't think of a better way to do it than hardcoding the title
// position relative to the EpisodeWidget container gtk::Box.
fn dim_titles(episodes: &gtk::ListBox) -> Option<()> {
let children = episodes.get_children();
for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let foo = container
.get_children()
.remove(0)
.downcast::<gtk::Box>()
.ok()?;
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let title = bar.get_children().remove(0).downcast::<gtk::Label>().ok()?;
title.get_style_context().map(|c| c.add_class("dim-label"));
}
Some(())
}
+168
View File
@@ -0,0 +1,168 @@
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use app::Action;
use utils::{self, get_ignored_shows, lazy_load, set_image_from_path};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
lazy_static! {
static ref SHOWS_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> = Mutex::new(None);
}
#[derive(Debug, Clone)]
pub struct ShowsView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsView {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsView {
pub fn new(sender: Sender<Action>) -> Result<Rc<Self>, Error> {
let pop = Rc::new(ShowsView::default());
pop.init(sender);
// Populate the flowbox with the Podcasts.
populate_flowbox(&pop)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) {
self.flowbox.connect_child_activated(move |_, child| {
on_child_activate(child, &sender)
.map_err(|err| error!("Error along flowbox child activation: {}", err))
.ok();
});
}
/// Set scrolled window vertical adjustment.
#[allow(unused)]
fn set_vadjustment(&self) -> Result<(), Error> {
let guard = SHOWS_VIEW_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = SHOWS_VIEW_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn populate_flowbox(shows: &Rc<ShowsView>) -> Result<(), Error> {
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
let constructor = move |parent| ShowsChild::new(&parent).child;
let callback = clone!(shows => move || {
shows.set_vadjustment()
.map_err(|err| error!("Failed to set ShowsView Alignment: {}", err))
.ok();
});
let flowbox = shows.flowbox.clone();
lazy_load(podcasts, flowbox, constructor, callback);
Ok(())
}
fn on_child_activate(child: &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());
self.set_cover(pd.id())
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
}
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
set_image_from_path(&self.cover, podcast_id, 256)
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<Project xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
xmlns:foaf="http://xmlns.com/foaf/0.1/"
xmlns:gnome="http://api.gnome.org/doap-extensions#"
xmlns="http://usefulinc.com/ns/doap#">
<name xml:lang="en">Hammond</name>
<shortdesc xml:lang="en">
Modern Podcast Client for the GNOME desktop
</shortdesc>
<description xml:lang="en">
<p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
desktop written in Rust.
</p>
</description>
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
<bug-database rdf:resource="https://gitlab.gnome.org/World/hammond/issues" />
<category rdf:resource="http://api.gnome.org/doap-extensions#apps" />
<programming-language>Rust</programming-language>
<maintainer>
<foaf:Person>
<foaf:name>Jordan petridis</foaf:name>
<foaf:mbox rdf:resource="mailto:jpetridis@gnome.org" />
<gnome:userid>jpetridis</gnome:userid>
</foaf:Person>
</maintainer>
</Project>
+1 -1
View File
@@ -3,7 +3,7 @@
project(
'hammond', 'rust',
version: '0.3.0',
version: '0.3.4',
license: 'GPLv3',
)
+10 -4
View File
@@ -1,7 +1,7 @@
{
"app-id" : "org.gnome.Hammond",
"runtime" : "org.gnome.Platform",
"runtime-version" : "master",
"runtime-version" : "3.28",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
@@ -12,6 +12,10 @@
],
"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",
@@ -24,17 +28,19 @@
"--share=network"
],
"env" : {
"CARGO_HOME" : "/run/build/Hammond/cargo"
"CARGO_HOME" : "/run/build/Hammond/cargo",
"RUST_BACKTRACE" : "1",
"DEBUG" : "true"
}
},
"modules" : [
{
"name" : "Hammond",
"name" : "hammond",
"buildsystem" : "meson",
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/alatiera/Hammond.git",
"url" : "https://gitlab.gnome.org/World/hammond.git",
"branch" : "master"
}
]
-4
View File
@@ -1,15 +1,11 @@
unstable_features = true
verbose = false
max_width = 100
comment_width = 100
wrap_comments = true
tab_spaces = 4
hard_tabs = false
newline_style = "Unix"
write_mode = "Overwrite"
condense_wildcard_suffixes = false
format_strings = true
normalize_comments = true
reorder_imports = true
reorder_imported_names = true
reorder_imports_in_group = true
+8
View File
@@ -1,5 +1,13 @@
#!/bin/sh
export CARGO_HOME=$1/target/cargo-home
export RUSTFLAGS="--cfg rayon_unstable"
if [[ $DEBUG = true ]]
then
echo "DEBUG MODE"
cargo build -p hammond-gtk && cp $1/target/debug/hammond-gtk $2
else
echo "RELEASE MODE"
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
fi
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import os
import subprocess
schemadir = os.path.join(os.environ['MESON_INSTALL_PREFIX'], 'share', 'glib-2.0', 'schemas')
if not os.environ.get('DESTDIR'):
print('Compiling gsettings schemas...')
subprocess.call(['glib-compile-schemas', schemadir])
+1
View File
@@ -13,6 +13,7 @@ cp -rf hammond-data $DIST
cp -rf hammond-gtk $DIST
cp -rf hammond-downloader $DIST
cp Cargo.toml $DIST
cp Cargo.lock $DIST
cp configure $DIST
cp meson.build $DIST
cp Hammond.doap $DIST