177 Commits
Author SHA1 Message Date
Jordan Petridis 85983ec490 Load **every** episode for benchmark purposes. 2018-04-07 00:26:02 +03:00
Jordan Petridis 7abf6bcade Lazy_load: remove unnecessary clone of an Rc pointer. 2018-04-06 23:12:32 +03:00
Jordan Petridis 854581f0bf Lazy_load: Use IntoIterator for T, instead of Iterator. 2018-04-06 22:56:44 +03:00
Jordan Petridis c79a92f3b2 Lazy_load: accept an iterator instead a Vec<_> over T. 2018-04-06 22:37:40 +03:00
Jordan Petridis 4d6c3a67b1 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-06 22:37:36 +03:00
Jordan Petridis 83abb5a825 Move the lazy_load logic to a Generic function. 2018-04-06 22:37:33 +03:00
Jordan Petridis d618771125 EpisodesListBox: Do not block while fetching episode backlog. 2018-04-06 17:06:14 +00:00
Jordan Petridis 45c9fd308d EpisodesListBox: Add each widget lazyly. 2018-04-06 17:06:14 +00:00
Jordan Petridis 0c00ee1320 ShowWidget: Initial Lazier evaluation of the widgets. 2018-04-06 17:06:13 +00: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
57 changed files with 3529 additions and 1428 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
+92 -46
View File
@@ -1,61 +1,107 @@
stages:
# meson uses cargo to do the build
# so it's ok to have the tests first.
- test
# - build
- lint
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential
- apt-get install -yqq --no-install-recommends libgtk-3-dev
# - apt-get install -yqq --no-install-recommends meson
.cargo_test_template: &cargo_test
stage: test
script:
- rustc --version && cargo --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test --verbose -- --test-threads=1
- cargo test --verbose -- --test-threads=1 --ignored
stage: test
variables:
# RUSTFLAGS: "-C link-dead-code"
RUST_BACKTRACE: "FULL"
CARGO_HOME: $CI_PROJECT_DIR/cargo
# variables:
# RUSTFLAGS: "-C link-dead-code"
# RUST_BACKTRACE: "FULL"
stable:test:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson
# nightly:test:
# # https://hub.docker.com/r/rustlang/rust/
# image: "rustlang/rust:nightly"
# <<: *cargo_test
- mkdir -p .cargo_cache
# Only stuff inside the repo directory can be cached
# Override the CARGO_HOME variable to force it location
- export CARGO_HOME="${PWD}/.cargo_cache"
script:
- rustc -Vv && cargo -Vv
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test -- --test-threads=1
- cargo test -- --test-threads=1 --ignored
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
rust:stable:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
rust:nightly:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
only:
- schedule
- web
flatpak:
image: registry.gitlab.com/alatiera/gnome-nightly-oci/rust-bundle:latest
stage: test
script:
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/alatiera/Hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
# Build the flatpak repo
- flatpak-builder --run app org.gnome.Hammond.json meson --prefix=/app --libdir=/app/lib _build
- flatpak-builder --run app org.gnome.Hammond.json ninja -C _build install
- flatpak-builder --finish-only app org.gnome.Hammond.json
- flatpak build-export repo app
# Create a flatpak bundle
- flatpak build-bundle repo hammond-dev.flatpak org.gnome.Hammond
# Run the tests
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored
artifacts:
paths:
- hammond-dev.flatpak
expire_in: 2 days
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- .flatpak-builder/cache/
- target/
# Configure and run rustfmt on nightly
# Exits and builds fails if on bad format
rustfmt:
image: "rustlang/rust:nightly"
stage: lint
variables:
CFG_RELEASE_CHANNEL: "nightly"
script:
- rustc --version && cargo --version
- cargo install rustfmt-nightly --force
- cargo fmt --all -- --write-mode=diff
image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:nightly"
stage: lint
script:
- rustc -Vv && cargo -Vv
- cargo fmt --version
- cargo fmt --all -- --write-mode=diff
# Configure and run clippy on nightly
# Only fails on errors atm.
# clippy:
# image: "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: "rustlang/rust:nightly"
stage: lint
script:
- rustc --version && cargo --version
- cargo install clippy --force
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all
when: manual
+26
View File
@@ -6,6 +6,32 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
* Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/alatiera/Hammond/issues/7)
* Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/alatiera/Hammond/issues/25)
## [0.3.1] - 2018-03-28
* Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/alatiera/Hammond/issues/47)
* Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/alatiera/Hammond/issues/49)
* EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/18)
* Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/alatiera/Hammond/issues/35)
* Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/alatiera/Hammond/issues/52)
* Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/alatiera/Hammond/issues/53)
* Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/alatiera/Hammond/issues/50)
* Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/22) [!23](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/23)
* Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/alatiera/Hammond/issues/44)
## [0.3.0] - 2018-02-11
* Tobias Bernard Redesigned the whole Gtk+ client.
Generated
+719 -497
View File
File diff suppressed because it is too large Load Diff
View File
+22 -29
View File
@@ -3,6 +3,7 @@
## 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)
[![Dependency Status](https://dependencyci.com/github/alatiera/Hammond/badge)](https://dependencyci.com/github/alatiera/Hammond)
### Features
@@ -14,22 +15,10 @@
## 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?
@@ -44,6 +33,8 @@ 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,20 +45,22 @@ 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-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
flatpak build-bundle repo hammond org.gnome.Hammond
```
## Building from soure
### Building from soure
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
cd Hammond/
cd hammond/
./configure --prefix=/usr/local
make && sudo make install
```
@@ -76,33 +69,31 @@ make && sudo make install
You can run `sudo make uninstall` for removal
### Dependencies
#### Dependencies
* Rust stable 1.22 or later.
* Rust stable 1.22 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
@@ -151,3 +142,5 @@ 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
+13 -15
View File
@@ -5,25 +5,22 @@ version = "0.1.0"
workspace = "../"
[dependencies]
ammonia = "1.0.1"
chrono = "0.4.0"
ammonia = "1.1.0"
chrono = "0.4.1"
derive_builder = "0.5.1"
dotenv = "0.10.1"
error-chain = "0.11.0"
itertools = "0.7.6"
itertools = "0.7.8"
lazy_static = "1.0.0"
log = "0.4.1"
rayon = "0.9.0"
rayon = "1.0.1"
rfc822_sanitizer = "0.3.3"
rss = "1.2.1"
url = "1.6.0"
rss = "1.4.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"
futures = "0.1.21"
hyper = "0.11.24"
tokio-core = "0.1.16"
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"
@@ -38,8 +35,9 @@ version = "1.1.0"
[dev-dependencies]
rand = "0.4.2"
tempdir = "0.3.6"
criterion = "0.2.0"
tempdir = "0.3.7"
criterion = "0.2.2"
pretty_assertions = "0.5.1"
[[bench]]
name = "bench"
-2
View File
@@ -3,7 +3,6 @@ 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,7 +14,6 @@ extern crate rss;
// use rayon::prelude::*;
// use futures::future::*;
// use futures_cpupool::CpuPool;
use tokio_core::reactor::Core;
use hammond_data::FeedBuilder;
+2 -5
View File
@@ -16,7 +16,7 @@ type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
embed_migrations!("migrations/");
lazy_static!{
lazy_static! {
static ref POOL: Pool = init_pool(DB_PATH.to_str().unwrap());
}
@@ -30,10 +30,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");
}
+55 -3
View File
@@ -33,6 +33,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_any(filter_ids))
.load::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
let db = connection();
@@ -102,7 +114,10 @@ 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> {
pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32],
limit: u32,
) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode;
let db = connection();
let con = db.get()?;
@@ -120,6 +135,7 @@ pub fn get_episodes_widgets_with_limit(limit: u32) -> Result<Vec<EpisodeWidgetQu
episode::podcast_id,
))
.order(episode::epoch.desc())
.filter(episode::podcast_id.ne_any(filter_ids))
.limit(i64::from(limit))
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
@@ -159,13 +175,23 @@ pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
.map_err(From::from)
}
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
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()?;
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
.filter(podcast_id.eq(parent.id()))
// .filter(podcast_id.eq(parent.id()))
// .group_by(epoch)
.order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con)
@@ -348,3 +374,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();
index_single_source(source, true).unwrap();
let pd = get_podcast_from_source_id(id).unwrap();
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_ne!(eps_num, 0);
update_none_to_played_now(&pd).unwrap();
let eps_num2 = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_eq!(eps_num2, 0);
}
}
+8 -9
View File
@@ -3,16 +3,10 @@ use diesel::r2d2;
use diesel_migrations::RunMigrationsError;
use hyper;
use native_tls;
// use rss;
use rss;
use url;
use std::io;
// use std::fmt;
// fadsadfs NOT SYNC
// #[derive(Fail, Debug)]
// #[fail(display = "RSS Error: {}", _0)]
// struct RSSError(rss::Error);
#[derive(Fail, Debug)]
pub enum DataError {
@@ -34,8 +28,7 @@ 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 = "Error: {}", _0)]
Bail(String),
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
@@ -100,6 +93,12 @@ impl From<io::Error> for DataError {
}
}
impl From<rss::Error> for DataError {
fn from(err: rss::Error) -> Self {
DataError::RssError(err)
}
}
impl From<String> for DataError {
fn from(err: String) -> Self {
DataError::Bail(err)
+1 -1
View File
@@ -96,7 +96,7 @@ impl Feed {
// 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),
IndexState::NotChanged => Err(DataError::EpisodeNotChanged),
_ => Ok(x),
})
})
+4 -1
View File
@@ -20,6 +20,10 @@
//! FIXME: Docs
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
#[macro_use]
extern crate derive_builder;
#[macro_use]
@@ -38,7 +42,6 @@ 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;
+6 -3
View File
@@ -32,7 +32,8 @@ pub struct Episode {
}
impl Save<Episode, DataError> for Episode {
/// Helper method to easily save/"sync" current state of self to the Database.
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Episode, DataError> {
let db = connection();
let tempdb = db.get()?;
@@ -224,7 +225,8 @@ impl From<Episode> for EpisodeWidgetQuery {
}
impl Save<usize, DataError> for EpisodeWidgetQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
@@ -362,7 +364,8 @@ pub struct EpisodeCleanerQuery {
}
impl Save<usize, DataError> for EpisodeCleanerQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
+2 -1
View File
@@ -45,6 +45,7 @@ pub trait Index<T, E>: Insert<T, E> + Update<T, E> {
/// FIXME: DOCS
pub trait Save<T, E> {
/// Helper method to easily save/"sync" current state of a diesel model to the Database.
/// Helper method to easily save/"sync" current state of a diesel model to
/// the Database.
fn save(&self) -> Result<T, E>;
}
+38 -49
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"]
@@ -74,7 +74,8 @@ 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.
// 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())?;
@@ -185,7 +186,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 +196,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()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s)));
// 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 +215,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 +231,10 @@ 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().map(|s| ammonia::clean(s));
NewEpisodeBuilder::default()
.title(self.title)
@@ -310,7 +306,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 +319,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 +340,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 +364,6 @@ mod tests {
.build()
.unwrap()
};
static ref UPDATED_DURATION_INTERCEPTED_1: NewEpisode = {
NewEpisodeBuilder::default()
.title("The Super Bowl of Racism")
@@ -387,7 +379,6 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_1: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Hacking Devices with Kali Linux | LUP 214")
@@ -401,7 +392,6 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_2: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Gnome Does it Again | LUP 213")
@@ -415,12 +405,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 +425,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")
+60 -53
View File
@@ -1,7 +1,6 @@
use ammonia;
use diesel;
use diesel::prelude::*;
use ammonia;
use rss;
use errors::DataError;
@@ -11,7 +10,7 @@ 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"]
@@ -90,22 +89,15 @@ impl NewPodcast {
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
let title = chan.title().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::clean(chan.description().trim());
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(|s| s.to_owned());
// If itunes is None, try to get the channel.image from the rss spec
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().to_owned()));
NewPodcastBuilder::default()
.title(title)
@@ -162,13 +154,14 @@ mod tests {
use std::io::BufReader;
// Pre-built expected NewPodcast structs.
lazy_static!{
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 +176,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 +192,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,18 +214,16 @@ 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 img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
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";
NewPodcastBuilder::default()
.title("Steal the Stars")
@@ -245,11 +234,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 +250,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 +321,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 +349,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 -1
View File
@@ -26,7 +26,8 @@ pub struct Podcast {
}
impl Save<Podcast, DataError> for Podcast {
/// Helper method to easily save/"sync" current state of self to the Database.
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Podcast, DataError> {
let db = connection();
let tempdb = db.get()?;
+32 -70
View File
@@ -6,12 +6,11 @@ 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};
Location, UserAgent};
use hyper_tls::HttpsConnector;
// use futures::future::ok;
use futures::prelude::*;
use futures_cpupool::CpuPool;
use database::connection;
use errors::DataError;
@@ -34,7 +33,8 @@ pub struct Source {
}
impl Save<Source, DataError> for Source {
/// Helper method to easily save/"sync" current state of self to the Database.
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Source, DataError> {
let db = connection();
let con = db.get()?;
@@ -101,6 +101,14 @@ impl Source {
Ok(())
}
fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: context.into(),
}
}
// TODO match on more stuff
// 301: Moved Permanently
// 304: Up to date Feed, checked with the Etag
@@ -114,67 +122,21 @@ impl Source {
fn match_status(mut self, res: Response) -> Result<(Self, 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(self.make_err("301: Feed was moved permanently.", code));
}
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))
@@ -216,12 +178,11 @@ impl Source {
pub fn into_feed(
self,
client: &Client<HttpsConnector<HttpConnector>>,
pool: CpuPool,
ignore_etags: bool,
) -> Box<Future<Item = Feed, Error = DataError>> {
let id = self.id();
let feed = self.request_constructor(client, ignore_etags)
.and_then(move |(_, res)| response_to_channel(res, pool))
.and_then(move |(_, res)| response_to_channel(res))
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
@@ -244,6 +205,12 @@ impl Source {
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::new(Method::Get, uri);
// Set the user agent as a fix for issue #53
// TODO: keep this in sync with tor-browser releases
req.headers_mut().set(UserAgent::new(
"Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0",
));
if !ignore_etags {
if let Some(foo) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(vec![
@@ -267,21 +234,17 @@ impl Source {
}
}
fn response_to_channel(
res: Response,
pool: CpuPool,
) -> Box<Future<Item = Channel, Error = DataError> + Send> {
#[allow(needless_pass_by_value)]
fn response_to_channel(res: Response) -> Box<Future<Item = Channel, Error = DataError> + Send> {
let chan = res.body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| {
Channel::from_str(&buf).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));
Box::new(chan)
}
#[cfg(test)]
@@ -296,7 +259,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 +269,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);
+7 -27
View File
@@ -2,7 +2,6 @@
//! Docs.
use futures::future::*;
use futures_cpupool::CpuPool;
// use futures::prelude::*;
use hyper::Client;
@@ -20,23 +19,6 @@ use models::{IndexState, NewEpisode, NewEpisodeMinimal};
// use std::sync::{Arc, Mutex};
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`.
///
@@ -47,13 +29,12 @@ pub fn pipeline<S: IntoIterator<Item = Source>>(
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(move |s| s.into_feed(&client, ignore_etags))
.map(|fut| fut.and_then(|feed| feed.index()))
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
.collect();
@@ -67,25 +48,24 @@ pub fn pipeline<S: IntoIterator<Item = Source>>(
Ok(())
}
/// Creates a tokio `reactor::Core`, a `CpuPool`, and a `hyper::Client` and runs the pipeline.
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
/// runs the pipeline.
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
if sources.is_empty() {
return Ok(());
}
let 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)
pipeline(sources, ignore_etags, &mut core, 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();
@@ -93,8 +73,8 @@ pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataErro
.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())))
let work = s.into_feed(&client, ignore_etags)
.and_then(move |feed| feed.index())
.map(|_| ());
core.run(work)
+16 -51
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()?;
@@ -38,17 +38,15 @@ fn download_checker() -> Result<(), DataError> {
}
/// 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());
@@ -91,10 +89,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 +109,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 +163,7 @@ mod tests {
use self::tempdir::TempDir;
use super::*;
use chrono::Duration;
use database::truncate_db;
use models::NewEpisodeBuilder;
@@ -260,15 +243,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 +258,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 +280,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.24"
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"
+7 -3
View File
@@ -18,7 +18,8 @@ use hammond_data::xdg_dirs::HAMMOND_CACHE;
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,
+6
View File
@@ -1,5 +1,7 @@
#![recursion_limit = "1024"]
#![deny(unused_extern_crates, unused)]
#![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
extern crate failure;
#[macro_use]
@@ -7,6 +9,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 -10
View File
@@ -6,29 +6,40 @@ 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.1"
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"
rayon = "1.0.1"
send-cell = "0.1.2"
url = "1.6.0"
url = "1.7.0"
failure = "0.1.1"
failure_derive = "0.1.1"
take_mut = "0.2.2"
regex = "0.2.10"
reqwest = "0.8.5"
serde_json = "1.0.13"
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
[dependencies.gtk]
features = ["v3_22"]
version = "0.3.0"
version = "0.4.0"
[dependencies.gio]
features = ["v2_50"]
version = "0.4.0"
[dependencies.hammond-data]
path = "../hammond-data"
[dependencies.hammond-downloader]
path = "../hammond-downloader"
[dev-dependencies]
pretty_assertions = "0.5.1"
+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>
+16 -21
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>
@@ -43,14 +44,12 @@ Tobias Bernard
<object class="GtkBox">
<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>
@@ -58,7 +57,6 @@ Tobias Bernard
<property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</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>
@@ -66,14 +64,14 @@ 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>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
@@ -81,7 +79,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 +93,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 +109,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 +126,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 +142,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 +159,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 +176,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,21 +193,21 @@ 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>
@@ -242,7 +239,6 @@ Tobias Bernard
<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="valign">center</property>
<property name="always_show_image">True</property>
<child>
@@ -285,7 +281,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="padding">6</property>
<property name="pack_type">end</property>
<property name="position">1</property>
@@ -295,7 +291,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 +300,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 -23
View File
@@ -38,9 +38,10 @@ Tobias Bernard
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="height_request">400</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
@@ -49,30 +50,33 @@ 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="width_request">600</property>
<property name="height_request">-1</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>
@@ -80,6 +84,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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
@@ -87,6 +92,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 +101,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 +109,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,14 +126,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">0</property>
</packing>
</child>
@@ -134,6 +142,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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
@@ -149,7 +158,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 +166,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,14 +183,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">1</property>
</packing>
</child>
@@ -188,6 +199,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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
@@ -203,7 +215,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 +223,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,14 +240,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">2</property>
</packing>
</child>
@@ -242,6 +256,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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
@@ -257,7 +272,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 +280,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,14 +297,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">3</property>
</packing>
</child>
@@ -296,6 +313,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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
@@ -303,6 +321,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 +330,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 +338,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 +346,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 +356,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 +378,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>
+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>
+136 -123
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,7 +24,7 @@ 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 -->
@@ -37,9 +37,11 @@ Tobias Bernard
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="width_request">700</property>
<property name="height_request">500</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
@@ -48,55 +50,99 @@ 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="width_request">700</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="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkFrame">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<property name="valign">center</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">end</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child type="center">
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">end</property>
<property name="label">foo</property>
<property name="use_markup">True</property>
<property name="wrap">True</property>
<property name="wrap_mode">word-char</property>
<property name="max_width_chars">90</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="spacing">12</property>
<property name="hexpand">True</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">image-x-generic-symbolic</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -105,125 +151,61 @@ Tobias Bernard
</packing>
</child>
<child>
<object class="GtkBox">
<object class="GtkButton" id="link_button">
<property name="label" translatable="yes">Website</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child type="center">
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">end</property>
<property name="label" translatable="yes">Show description</property>
<property name="wrap">True</property>
<property name="max_width_chars">57</property>
<attributes>
<attribute name="weight" value="medium"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="link_button">
<property name="label" translatable="yes">Website</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">True</property>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
<child type="label_item">
<placeholder/>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
@@ -241,14 +223,14 @@ 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>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
@@ -256,14 +238,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>
@@ -279,4 +263,33 @@ Tobias Bernard
</packing>
</child>
</object>
<object class="GtkPopover" id="show_menu">
<property name="can_focus">False</property>
<property name="position">bottom</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton" id="mark_all_watched">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Mark all episodes as listened</property>
<property name="centered">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
-1
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>
+3 -1
View File
@@ -1,4 +1,6 @@
# subdir('icons')
install_data('org.gnome.Hammond.desktop', install_dir : datadir + '/applications')
install_data('org.gnome.Hammond.appdata.xml', install_dir : datadir + '/appdata')
install_data('org.gnome.Hammond.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/alatiera/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/alatiera/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.3.0" date="2018-02-11"/>
<release version="0.3.1" date="2018-03-28"/>
</releases>
<update_contact>jordanpetridis@protonmail.com</update_contact>
</component>
<url type="homepage">https://gitlab.gnome.org/alatiera/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name>
</component>
@@ -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>
+10 -8
View File
@@ -1,14 +1,16 @@
<?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>
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_show.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file>
<file compressed="true">gtk/style.css</file>
</gresource>
</gresources>
+129 -30
View File
@@ -1,14 +1,23 @@
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
#![allow(new_without_default)]
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt};
use glib;
use gtk;
use gtk::SettingsExt as GtkSettingsExt;
use gtk::prelude::*;
use hammond_data::{Podcast, Source};
use hammond_data::utils::checkup;
use failure::Error;
use rayon;
use hammond_data::{Podcast, Source};
use hammond_data::utils::delete_show;
use appnotif::*;
use headerbar::Header;
use settings::WindowGeometry;
use stacks::Content;
use utils;
use widgets::mark_all_watched;
use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
@@ -31,20 +40,25 @@ pub enum Action {
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>),
}
#[derive(Debug)]
pub struct App {
app_instance: gtk::Application,
window: gtk::Window,
overlay: gtk::Overlay,
header: Arc<Header>,
content: Arc<Content>,
receiver: Receiver<Action>,
sender: Sender<Action>,
settings: Settings,
}
impl App {
pub fn new() -> App {
let settings = Settings::new("org.gnome.Hammond");
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
.expect("Application Initialization failed...");
@@ -54,10 +68,14 @@ impl App {
// 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();
let window_clone = window.clone();
let settings_clone = settings.clone();
window.connect_delete_event(move |_, _| {
WindowGeometry::from_window(&window_clone).write(&settings_clone);
app_clone.quit();
Inhibit(false)
});
@@ -71,64 +89,91 @@ impl App {
// Create the headerbar
let header = Arc::new(Header::new(&content, &window, sender.clone()));
// Add the content main stack to the window.
window.add(&content.get_stack());
// Add the content main stack to the overlay.
let overlay = gtk::Overlay::new();
overlay.add(&content.get_stack());
// Add the overlay to the main window
window.add(&overlay);
App {
app_instance: application,
window,
overlay,
header,
content,
receiver,
sender,
settings,
}
}
pub fn setup_timed_callbacks(&self) {
let sender = self.sender.clone();
fn setup_timed_callbacks(&self) {
self.setup_dark_theme();
self.setup_refresh_on_startup();
self.setup_auto_refresh();
}
fn setup_dark_theme(&self) {
let settings = gtk::Settings::get_default().unwrap();
let enabled = self.settings.get_boolean("dark-theme");
settings.set_property_gtk_application_prefer_dark_theme(enabled);
}
fn setup_refresh_on_startup(&self) {
// Update the feeds right after the Application is initialized.
gtk::timeout_add_seconds(2, move || {
utils::refresh_feed_wrapper(None, sender.clone());
glib::Continue(false)
});
if self.settings.get_boolean("refresh-on-startup") {
let cleanup_date = utils::get_cleanup_date(&self.settings);
let sender = self.sender.clone();
info!("Refresh on startup.");
utils::cleanup(cleanup_date);
gtk::timeout_add_seconds(2, move || {
utils::refresh(None, sender.clone());
glib::Continue(false)
});
}
}
fn setup_auto_refresh(&self) {
let refresh_interval = utils::get_refresh_interval(&self.settings).num_seconds() as u32;
let sender = self.sender.clone();
// 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());
info!("Auto-refresh every {:?} seconds.", refresh_interval);
gtk::timeout_add_seconds(refresh_interval, move || {
utils::refresh(None, sender.clone());
glib::Continue(true)
});
// Run a database checkup once the application is initialized.
gtk::timeout_add(300, || {
if let Err(err) = checkup() {
error!("Check up failed: {}", err);
}
glib::Continue(false)
});
}
pub fn run(self) {
WindowGeometry::from_settings(&self.settings).apply(&self.window);
let window = self.window.clone();
let app = self.app_instance.clone();
self.app_instance.connect_startup(move |_| {
build_ui(&window, &app);
self.app_instance.connect_startup(move |app| {
build_ui(&window, app);
});
self.setup_timed_callbacks();
let content = self.content.clone();
let headerbar = self.header.clone();
let sender = self.sender.clone();
let overlay = self.overlay.clone();
let receiver = self.receiver;
gtk::idle_add(move || {
match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(Action::UpdateSources(source)) => {
if let Some(s) = source {
utils::refresh_feed_wrapper(Some(vec![s]), sender.clone());
utils::refresh(Some(vec![s]), sender.clone());
} else {
utils::refresh_feed_wrapper(None, sender.clone());
utils::refresh(None, sender.clone());
}
}
Ok(Action::RefreshAllViews) => content.update(),
@@ -150,6 +195,60 @@ impl App {
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
Ok(Action::MarkAllPlayerNotification(pd)) => {
let callback = clone!(sender => move || {
if let Err(err) = mark_all_watched(&pd, sender.clone()) {
error!("Something went horribly wrong with the notif callback: {}", err);
}
glib::Continue(false)
});
let text = "Marked all episodes as listened".into();
let notif = InAppNotification::new(text, callback, || {}, sender.clone());
notif.show(&overlay);
}
Ok(Action::RemoveShow(pd)) => {
let text = format!("Unsubscribed from {}", pd.title());
if let Err(err) = utils::ignore_show(pd.id()) {
error!("Could not insert {} to the ignore list.", pd.title());
error!("Error: {}", err);
}
let callback = clone!(pd => move || {
if let Err(err) = utils::uningore_show(pd.id()) {
error!("Could not remove {} from the ignore list.", pd.title());
error!("Error: {}", err);
}
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd => move || {
if let Err(err) = delete_show(&pd) {
error!("Something went wrong trying to remove {}", pd.title());
error!("Error: {}", err);
}
}));
glib::Continue(false)
});
let sender_ = sender.clone();
let undo_wrap = move || -> Result<(), Error> {
utils::uningore_show(pd.id())?;
sender_.send(Action::RefreshShowsView)?;
sender_.send(Action::RefreshEpisodesView)?;
Ok(())
};
let undo_callback = move || {
if let Err(err) = undo_wrap() {
error!("{}", err)
}
};
let sender_ = sender.clone();
let notif = InAppNotification::new(text, callback, undo_callback, sender_);
notif.show(&overlay);
}
Err(_) => (),
}
+95
View File
@@ -0,0 +1,95 @@
use glib;
use gtk;
use gtk::prelude::*;
use app::Action;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct InAppNotification {
revealer: gtk::Revealer,
text: gtk::Label,
undo: gtk::Button,
close: gtk::Button,
}
impl Default for InAppNotification {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/inapp_notif.ui");
let revealer: gtk::Revealer = builder.get_object("revealer").unwrap();
let text: gtk::Label = builder.get_object("text").unwrap();
let undo: gtk::Button = builder.get_object("undo").unwrap();
let close: gtk::Button = builder.get_object("close").unwrap();
InAppNotification {
revealer,
text,
undo,
close,
}
}
}
impl InAppNotification {
pub fn new<F, U>(
text: String,
mut callback: F,
undo_callback: U,
sender: Sender<Action>,
) -> Self
where
F: FnMut() -> glib::Continue + 'static,
U: Fn() + 'static,
{
let notif = InAppNotification::default();
notif.text.set_text(&text);
let revealer = notif.revealer.clone();
let id = timeout_add_seconds(6, move || {
revealer.set_reveal_child(false);
callback()
});
let id = Rc::new(RefCell::new(Some(id)));
// Cancel the callback
let revealer = notif.revealer.clone();
notif.undo.connect_clicked(move |_| {
let foo = id.borrow_mut().take();
if let Some(id) = foo {
glib::source::source_remove(id);
}
undo_callback();
// Hide the notification
revealer.set_reveal_child(false);
// Refresh the widget if visible
if let Err(err) = sender.send(Action::RefreshWidgetIfVis) {
error!("Action channel blew up: {}", err)
}
});
// Hide the revealer when the close button is clicked
let revealer = notif.revealer.clone();
notif.close.connect_clicked(move |_| {
revealer.set_reveal_child(false);
});
notif
}
// This is a seperate method cause in order to get a nice animation
// the revealer should be attached to something that displays it.
// Previously we where doing it in the constructor, which had the result
// of the animation being skipped cause there was no parent widget to display it.
pub fn show(&self, overlay: &gtk::Overlay) {
overlay.add_overlay(&self.revealer);
// We need to display the notification after the widget is added to the overlay
// so there will be a nice animation.
self.revealer.set_reveal_child(true);
}
}
+15 -2
View File
@@ -12,6 +12,7 @@ use std::sync::mpsc::Sender;
use app::Action;
use stacks::Content;
use utils::itunes_to_rss;
#[derive(Debug, Clone)]
pub struct Header {
@@ -154,8 +155,18 @@ impl Header {
}
}
// 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("");
@@ -165,6 +176,7 @@ fn on_add_bttn_clicked(entry: &gtk::Entry, sender: Sender<Action>) -> Result<(),
Ok(())
}
// FIXME: THIS SUCKS!
fn on_url_change(
entry: &gtk::Entry,
result: &gtk::Label,
@@ -214,19 +226,20 @@ fn about_dialog(window: &gtk::Window) {
"James Wykeham-Martin",
"Jordan Petridis",
"Julian Sparber",
"Rowan Lewis",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("A Podcast Client for the GNOME Desktop.");
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");
dialog.set_version("0.3.1");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
+18 -11
View File
@@ -1,5 +1,8 @@
#![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, needless_pass_by_value, useless_format, blacklisted_name,
match_same_arms))]
#![allow(unknown_lints)]
#![deny(unused_extern_crates, unused)]
extern crate gdk;
extern crate gdk_pixbuf;
@@ -16,18 +19,26 @@ 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 take_mut;
extern crate url;
// extern crate rayon;
// use rayon::prelude::*;
use log::Level;
use gtk::prelude::*;
@@ -60,9 +71,11 @@ pub mod stacks;
pub mod headerbar;
pub mod app;
pub mod settings;
pub mod utils;
pub mod manager;
pub mod static_resource;
pub mod appnotif;
use app::App;
@@ -81,11 +94,5 @@ fn main() {
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();
}
+21 -24
View File
@@ -1,4 +1,5 @@
use failure::Error;
use rayon;
// use hammond_data::Episode;
use hammond_data::dbqueries;
@@ -11,7 +12,6 @@ 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,43 +73,37 @@ 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, sender: Sender<Action>) -> 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 || {
DLPOOL.spawn(move || {
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
let pid = episode.podcast_id();
let id = episode.rowid();
if let Err(err) = get_episode(&mut episode.into(), dir.as_str(), Some(prog)) {
if let Err(err) = get_episode(&mut episode.into(), directory.as_str(), Some(prog)) {
error!("Error while trying to download an episode");
error!("Error: {}", err);
}
{
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
info!("Removed: {:?}", m.remove(&id));
}
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
let foo = m.remove(&id);
debug!("Removed: {:?}", foo);
}
// {
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
// }
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
// }
sender
@@ -163,18 +157,21 @@ mod tests {
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, sender).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";
+90
View File
@@ -0,0 +1,90 @@
use gio;
use gio::SettingsExt;
use gtk;
use gtk::GtkWindowExt;
pub struct WindowGeometry {
left: i32,
top: i32,
width: i32,
height: i32,
is_maximized: bool,
}
impl WindowGeometry {
pub fn from_window(window: &gtk::Window) -> WindowGeometry {
let position = window.get_position();
let size = window.get_size();
let left = position.0;
let top = position.1;
let width = size.0;
let height = size.1;
let is_maximized = window.is_maximized();
WindowGeometry {
left,
top,
width,
height,
is_maximized,
}
}
pub fn from_settings(settings: &gio::Settings) -> WindowGeometry {
let top = settings.get_int("persist-window-geometry-top");
let left = settings.get_int("persist-window-geometry-left");
let width = settings.get_int("persist-window-geometry-width");
let height = settings.get_int("persist-window-geometry-height");
let is_maximized = settings.get_boolean("persist-window-geometry-maximized");
WindowGeometry {
left,
top,
width,
height,
is_maximized,
}
}
pub fn apply(&self, window: &gtk::Window) {
if self.width > 0 && self.height > 0 {
window.resize(self.width, self.height);
}
if self.is_maximized {
window.maximize();
} else if self.top > 0 && self.left > 0 {
window.move_(self.left, self.top);
}
}
pub fn write(&self, settings: &gio::Settings) {
settings.set_int("persist-window-geometry-left", self.left);
settings.set_int("persist-window-geometry-top", self.top);
settings.set_int("persist-window-geometry-width", self.width);
settings.set_int("persist-window-geometry-height", self.height);
settings.set_boolean("persist-window-geometry-maximized", self.is_maximized);
}
}
// #[test]
// fn test_apply_window_geometry() {
// gtk::init().expect("Error initializing gtk.");
// let window = gtk::Window::new(gtk::WindowType::Toplevel);
// let _geometry = WindowGeometry {
// left: 0,
// top: 0,
// width: 100,
// height: 100,
// is_maximized: true
// };
// assert!(!window.is_maximized());
// window.show();
// window.activate();
// geometry.apply(&window);
// assert!(window.is_maximized());
// }
+3 -2
View File
@@ -102,8 +102,9 @@ impl ShowStack {
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.
// 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);
+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(())
+247 -51
View File
@@ -1,48 +1,108 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use failure::Error;
use gdk_pixbuf::Pixbuf;
use gio::{Settings, SettingsExt};
use glib;
use gtk;
use gtk::prelude::*;
use failure::Error;
use rayon;
use regex::Regex;
use reqwest;
use send_cell::SendCell;
use serde_json::Value;
// use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::utils::checkup;
use hammond_downloader::downloader;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, RwLock};
use std::sync::mpsc::Sender;
use std::thread;
use std::sync::Arc;
use std::sync::mpsc::*;
use app::Action;
pub fn refresh_feed_wrapper(source: Option<Vec<Source>>, sender: Sender<Action>) {
use chrono::Duration;
use chrono::prelude::*;
lazy_static! {
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
}
pub fn ignore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS
.lock()
.map(|mut guard| guard.insert(id))
.map_err(|err| format_err!("{}", err))
}
pub fn uningore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS
.lock()
.map(|mut guard| guard.remove(&id))
.map_err(|err| format_err!("{}", err))
}
pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
IGNORESHOWS
.lock()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
.map_err(|err| format_err!("{}", err))
}
pub fn cleanup(cleanup_date: DateTime<Utc>) {
if let Err(err) = checkup(cleanup_date) {
error!("Check up failed: {}", err);
}
}
pub fn refresh(source: Option<Vec<Source>>, sender: Sender<Action>) {
if let Err(err) = refresh_feed(source, sender) {
error!("An error occured while trying to update the feeds.");
error!("Error: {}", err);
}
}
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = settings.get_int("refresh-interval-time") as i64;
let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str())
}
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = settings.get_int("cleanup-age-time") as i64;
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
}
/// Update the rss feed(s) originating from `source`.
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
/// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
sender.send(Action::HeaderBarShowUpdateIndicator)?;
thread::spawn(move || {
rayon::spawn(move || {
let mut sources = source.unwrap_or_else(|| {
dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
});
// Work around to improve the feed addition experience.
// Many times links to rss feeds are just redirects(usually to an https version).
// Sadly I haven't figured yet a nice way to follow up links redirects without getting
// to lifetime hell with futures and hyper.
// 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.
// (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();
@@ -56,12 +116,10 @@ fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(
}
}
}
} 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);
}
// This is what would normally run
} else if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
sender
@@ -75,9 +133,10 @@ fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(
}
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 +146,185 @@ 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,
pd: Arc<PodcastCoverQuery>,
size: u32,
) -> Result<(), Error> {
// Check if there's an active download about this show cover.
// If there is, a callback will be set so this function will be called again.
// If the download succedes, there should be a quick return from the pixbuf cache_image
// If it fails another download will be scheduled.
if let Ok(guard) = COVER_DL_REGISTRY.read() {
if guard.contains(&pd.id()) {
let callback = clone!(image, pd => move || {
let _ = set_image_from_path(&image, pd.clone(), size);
glib::Continue(false)
});
gtk::timeout_add(250, callback);
return Ok(());
}
}
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(&(pd.id(), size)) {
guard
.lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err))
.and_then(|sendcell| {
sendcell
.try_get()
.map(|px| image.set_from_pixbuf(px))
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
})?;
return Ok(());
}
}
let (sender, receiver) = channel();
let pd_ = pd.clone();
THREADPOOL.spawn(move || {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.insert(pd_.id());
}
let _ = sender.send(downloader::cache_image(&pd_));
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&pd_.id());
}
});
let image = image.clone();
let s = size as i32;
gtk::timeout_add(25, move || {
if let Ok(path) = receiver.try_recv() {
if let Ok(path) = path {
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px);
}
}
}
glib::Continue(false)
} else {
glib::Continue(true)
}
});
Ok(())
}
#[inline]
// FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>`
pub fn itunes_to_rss(url: &str) -> Result<String, Error> {
let id = itunes_id_from_url(url).ok_or_else(|| format_err!("Failed to find an Itunes ID."))?;
lookup_id(id)
}
#[inline]
fn itunes_id_from_url(url: &str) -> Option<u32> {
lazy_static! {
static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
}
// Get the itunes id from the url
let foo = RE.captures_iter(url).nth(0)?.get(1)?.as_str();
// Parse it to a u32, this *should* never fail
foo.parse::<u32>().ok()
}
#[inline]
fn lookup_id(id: u32) -> Result<String, Error> {
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
let req: Value = reqwest::get(&url)?.json()?;
let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
rssurl()
.map(From::from)
.ok_or_else(|| format_err!("Failed to get url from itunes response"))
}
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period {
"weeks" => Duration::weeks(time),
"days" => Duration::days(time),
"hours" => Duration::hours(time),
"minutes" => Duration::minutes(time),
_ => Duration::seconds(time),
}
}
#[cfg(test)]
mod tests {
use super::*;
use hammond_data::Source;
use hammond_data::dbqueries;
// use hammond_data::Source;
// use hammond_data::dbqueries;
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
#[ignore]
fn test_get_pixbuf_from_path() {
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
let source = Source::from_url(url).unwrap();
// Copy it's id
let sid = source.id();
pipeline::run(vec![source], true).unwrap();
// #[ignore]
// Disabled till https://gitlab.gnome.org/alatiera/Hammond/issues/56
// fn test_set_image_from_path() {
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
// let source = Source::from_url(url).unwrap();
// Copy it's id
// let sid = source.id();
// pipeline::run(vec![source], true).unwrap();
// Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let pxbuf = get_pixbuf_from_path(&pd.into(), 256);
assert!(pxbuf.is_ok());
// Get the Podcast
// let img = gtk::Image::new();
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
// let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
// assert!(pxbuf.is_ok());
// }
#[test]
fn test_itunes_to_rss() {
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
assert!(itunes_to_rss(itunes_url).is_err());
}
#[test]
fn test_itunes_id() {
let id = 1195206601;
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
}
#[test]
fn test_itunes_lookup_id() {
let id = 1195206601;
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
assert_eq!(rss_url, lookup_id(id).unwrap());
let id = 000000000;
assert!(lookup_id(id).is_err());
}
}
+6 -6
View File
@@ -7,9 +7,10 @@ use hammond_data::EpisodeWidgetQuery;
use hammond_data::dbqueries;
use app::Action;
use utils::get_pixbuf_from_path;
use utils::{get_ignored_shows, set_image_from_path};
use widgets::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
@@ -77,7 +78,8 @@ impl Default for EpisodesView {
impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
let view = EpisodesView::default();
let episodes = dbqueries::get_episodes_widgets_with_limit(50)?;
let ignore = get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 50)?;
let now_utc = Utc::now();
episodes.into_iter().for_each(|ep| {
@@ -226,9 +228,7 @@ impl EpisodesViewWidget {
}
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(())
let pd = Arc::new(dbqueries::get_podcast_cover_from_id(podcast_id)?);
set_image_from_path(&self.image, pd, 64)
}
}
+15 -19
View File
@@ -2,11 +2,11 @@ use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::Podcast;
use hammond_data::{Podcast, PodcastCoverQuery};
use hammond_data::dbqueries;
use app::Action;
use utils::get_pixbuf_from_path;
use utils::{get_ignored_shows, set_image_from_path};
use std::sync::Arc;
use std::sync::mpsc::Sender;
@@ -54,15 +54,13 @@ impl ShowsPopulated {
}
fn populate_flowbox(&self) -> Result<(), Error> {
let podcasts = dbqueries::get_podcasts()?;
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
podcasts
.into_iter()
.map(|pd| Arc::new(pd))
.for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
podcasts.into_iter().for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
Ok(())
}
@@ -118,25 +116,23 @@ impl Default for ShowsChild {
}
impl ShowsChild {
pub fn new(pd: Arc<Podcast>) -> ShowsChild {
pub fn new(pd: Podcast) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: Arc<Podcast>) {
fn init(&self, pd: Podcast) {
self.container.set_tooltip_text(pd.title());
WidgetExt::set_name(&self.child, &pd.id().to_string());
if let Err(err) = self.set_cover(pd.clone()) {
let pd = Arc::new(pd.into());
if let Err(err) = self.set_cover(pd) {
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(())
fn set_cover(&self, pd: Arc<PodcastCoverQuery>) -> Result<(), Error> {
set_image_from_path(&self.cover, pd, 256)
}
}
+163 -183
View File
@@ -1,13 +1,12 @@
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use chrono::Duration;
use failure::Error;
use humansize::{file_size_opts as size_opts, FileSize};
use humansize::FileSize;
use open;
use rayon;
use take_mut;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries;
@@ -15,46 +14,22 @@ use hammond_data::utils::get_download_folder;
use app::Action;
use manager;
use widgets::episode_states::*;
use std::cell::RefCell;
use std::ops::DerefMut;
use std::path::Path;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::Sender;
lazy_static! {
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
Arc::new(size_opts::FileSizeOpts {
divider: size_opts::Kilo::Binary,
units: size_opts::Kilo::Decimal,
decimal_places: 0,
decimal_zeroes: 0,
fixed_at: size_opts::FixedAt::No,
long_units: false,
space: true,
suffix: "",
allow_negative: false,
})
};
static ref NOW: DateTime<Utc> = Utc::now();
}
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct EpisodeWidget {
pub container: gtk::Box,
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,
date: DateMachine,
duration: DurationMachine,
title: Rc<RefCell<TitleMachine>>,
media: Arc<Mutex<MediaMachine>>,
}
impl Default for EpisodeWidget {
@@ -78,75 +53,72 @@ impl Default for EpisodeWidget {
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
EpisodeWidget {
container,
progress,
download,
let date_machine = DateMachine::new(date, 0);
let dur_machine = DurationMachine::new(duration, separator1, None);
let title_machine = Rc::new(RefCell::new(TitleMachine::new(title, false)));
let media = MediaMachine::new(
play,
download,
progress,
cancel,
title,
duration,
date,
total_size,
local_size,
separator1,
separator2,
prog_separator,
);
let media_machine = Arc::new(Mutex::new(media));
EpisodeWidget {
container,
title: title_machine,
duration: dur_machine,
date: date_machine,
media: media_machine,
}
}
}
impl EpisodeWidget {
pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let widget = EpisodeWidget::default();
let mut widget = EpisodeWidget::default();
widget.init(episode, sender);
widget
}
fn init(&self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
fn init(&mut self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
// Set the date label.
self.set_date(episode.epoch());
// Set the title label state.
self.set_title(&episode);
// Set the duaration label.
self.set_duration(episode.duration());
// 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.");
// Determine what the state of the media widgets should be.
if let Err(err) = self.determine_media_state(&episode) {
error!("Something went wrong determining the Media State.");
error!("Error: {}", err);
}
let episode = Arc::new(Mutex::new(episode));
self.connect_buttons(episode, sender);
}
fn connect_buttons(&self, episode: Arc<Mutex<EpisodeWidgetQuery>>, sender: Sender<Action>) {
let title = self.title.clone();
self.play
.connect_clicked(clone!(episode, sender => move |_| {
if let Ok(media) = self.media.lock() {
media.play_connect_clicked(clone!(episode, sender => move |_| {
if let Ok(mut ep) = episode.lock() {
if let Err(err) = on_play_bttn_clicked(&mut ep, &title, sender.clone()){
if let Err(err) = on_play_bttn_clicked(&mut ep, title.clone(), sender.clone()){
error!("Error: {}", err);
};
}
}));
}));
self.download
.connect_clicked(clone!(episode, sender => move |dl| {
media.download_connect_clicked(clone!(episode, sender => move |dl| {
dl.set_sensitive(false);
if let Ok(ep) = episode.lock() {
if let Err(err) = on_download_clicked(&ep, sender.clone()) {
@@ -156,73 +128,32 @@ impl EpisodeWidget {
info!("Donwload started succesfully.");
}
}
}));
}
/// Show or hide the play/delete/download buttons upon widget initialization.
fn show_buttons(&self, local_uri: Option<&str>) -> Result<(), Error> {
let path = local_uri.ok_or_else(|| format_err!("Path is None"))?;
if Path::new(path).exists() {
self.download.hide();
self.play.show();
}));
}
Ok(())
}
/// Determine the title state.
fn set_title(&self, episode: &EpisodeWidgetQuery) {
self.title.set_text(episode.title());
// Grey out the title if the episode is played.
if episode.played().is_some() {
self.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
}
fn set_title(&mut self, episode: &EpisodeWidgetQuery) {
let mut machine = self.title.borrow_mut();
machine.set_title(episode.title());
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
}
/// Set the date label depending on the current time.
fn set_date(&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 set_date(&mut self, epoch: i32) {
let machine = &mut self.date;
take_mut::take(machine, |date| date.determine_state(i64::from(epoch)));
}
/// Set the duration label.
fn set_duration(&self, seconds: Option<i32>) -> Option<()> {
let minutes = Duration::seconds(seconds?.into()).num_minutes();
if minutes == 0 {
return None;
}
self.duration.set_text(&format!("{} min", minutes));
self.duration.show();
self.separator1.show();
Some(())
fn set_duration(&mut self, seconds: Option<i32>) {
let machine = &mut self.duration;
take_mut::take(machine, |duration| duration.determine_state(seconds));
}
/// 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.");
}
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(())
}
// FIXME: REFACTOR ME
// Something Something State-Machine?
fn determine_progess_bar(&self) -> Result<(), Error> {
fn determine_media_state(&self, episode: &EpisodeWidgetQuery) -> Result<(), Error> {
let id = WidgetExt::get_name(&self.container)
.ok_or_else(|| format_err!("Failed to get widget Name"))?
.parse::<i32>()?;
@@ -235,46 +166,40 @@ impl EpisodeWidget {
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();
let mut lock = self.media.lock().map_err(|err| format_err!("{}", err))?;
take_mut::take(lock.deref_mut(), |media| {
media.determine_state(
episode.length(),
active_dl.is_some(),
episode.local_uri().is_some(),
)
});
let progress_bar = self.progress.clone();
let total_size = self.total_size.clone();
let local_size = self.local_size.clone();
// Show or hide the play/delete/download buttons upon widget initialization.
if let Some(prog) = active_dl {
lock.cancel_connect_clicked(prog.clone());
drop(lock);
// Setup a callback that will update the progress bar.
update_progressbar_callback(prog.clone(), id, &progress_bar, &local_size);
update_progressbar_callback(prog.clone(), self.media.clone(), id);
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), &total_size);
self.cancel.connect_clicked(clone!(prog => move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
}));
update_total_size_callback(prog.clone(), self.media.clone());
}
Ok(())
}
}
#[inline]
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
let download_fold = get_download_folder(&pd.title().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, sender.clone())?;
// Update Views
sender.send(Action::RefreshEpisodesView)?;
@@ -283,18 +208,21 @@ fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Resul
Ok(())
}
#[inline]
fn on_play_bttn_clicked(
episode: &mut EpisodeWidgetQuery,
title: &gtk::Label,
title: Rc<RefCell<TitleMachine>>,
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)?;
};
let mut machine = title.try_borrow_mut()?;
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
sender.send(Action::RefreshEpisodesViewBGR)?;
Ok(())
}
@@ -313,27 +241,28 @@ 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>>,
media: Arc<Mutex<MediaMachine>>,
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)
clone!(prog, media => move || {
progress_bar_helper(prog.clone(), media.clone(), episode_rowid)
.unwrap_or(glib::Continue(false))
}),
);
}
#[inline]
#[allow(if_same_then_else)]
fn progress_bar_helper(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
episode_rowid: i32,
progress_bar: &gtk::ProgressBar,
local_size: &gtk::Label,
) -> Result<glib::Continue, Error> {
let (fraction, downloaded) = {
let m = prog.lock()
@@ -341,16 +270,16 @@ fn progress_bar_helper(
(m.get_fraction(), m.get_downloaded())
};
// 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))?;
let mut m = media.lock().unwrap();
m.update_progress(&size, fraction);
}
// info!("Fraction: {}", progress_bar.get_fraction());
@@ -376,18 +305,23 @@ 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) {
#[inline]
fn update_total_size_callback(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
) {
timeout_add(
500,
clone!(prog, total_size => move || {
total_size_helper(prog.clone(), &total_size).unwrap_or(glib::Continue(true))
clone!(prog, media => move || {
total_size_helper(prog.clone(), media.clone()).unwrap_or(glib::Continue(true))
}),
);
}
#[inline]
fn total_size_helper(
prog: Arc<Mutex<manager::Progress>>,
total_size: &gtk::Label,
media: Arc<Mutex<MediaMachine>>,
) -> Result<glib::Continue, Error> {
// Get the total_bytes.
let total_bytes = {
@@ -399,10 +333,12 @@ fn total_size_helper(
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))?;
if let Ok(mut m) = media.lock() {
take_mut::take(m.deref_mut(), |machine| {
machine.set_size(Some(total_bytes as i32))
});
}
// Do not call again the callback
Ok(glib::Continue(false))
} else {
@@ -415,19 +351,63 @@ fn total_size_helper(
// 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)?;
pub fn episodes_listbox(pd: Arc<Podcast>, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
use crossbeam_channel::TryRecvError::*;
use crossbeam_channel::bounded;
let list = gtk::ListBox::new();
let count = dbqueries::get_pd_episodes_count(&pd)?;
episodes.into_iter().for_each(|ep| {
let widget = EpisodeWidget::new(ep, sender.clone());
list.add(&widget.container);
let (sender_, receiver) = bounded(1);
rayon::spawn(move || {
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
sender_
.send(episodes)
.expect("Something terrible happened to the channnel");
});
list.set_vexpand(false);
list.set_hexpand(false);
let list = gtk::ListBox::new();
list.set_visible(true);
list.set_selection_mode(gtk::SelectionMode::None);
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();
list.add(&container);
return Ok(list);
}
gtk::idle_add(clone!(list => move || {
let episodes = match receiver.try_recv() {
Ok(e) => e,
Err(Empty) => return glib::Continue(true),
Err(Disconnected) => return glib::Continue(false),
};
lazy_load(episodes, list.clone(), clone!(sender => move |ep| {
EpisodeWidget::new(ep, sender.clone()).container
}));
glib::Continue(false)
}));
Ok(list)
}
use gtk::{IsA, Widget};
fn lazy_load<T, U, P, Z>(data: T, container: Z, mut predicate: P)
where
T: IntoIterator + 'static,
T::Item: 'static,
Z: ContainerExt + 'static,
P: FnMut(T::Item) -> U + 'static,
U: IsA<Widget>,
{
let mut data = data.into_iter();
gtk::idle_add(move || {
data.next()
.map(|x| container.add(&predicate(x)))
.map(|_| glib::Continue(true))
.unwrap_or(glib::Continue(false))
});
}
+917
View File
@@ -0,0 +1,917 @@
// TODO: Things that should be done.
//
// * Wherever there's a function that take 2 or more arguments of the same type,
// eg: fn new(total_size: gtk::Label, local_size: gtk::Label ..)
// Wrap the types into Struct-tuples and imple deref so it won't be possible to pass
// the wrong argument to the wrong position.
use chrono;
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use humansize::{file_size_opts as size_opts, FileSize};
use std::sync::{Arc, Mutex};
use manager::Progress as OtherProgress;
lazy_static! {
pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
Arc::new(size_opts::FileSizeOpts {
divider: size_opts::Kilo::Binary,
units: size_opts::Kilo::Decimal,
decimal_places: 0,
decimal_zeroes: 0,
fixed_at: size_opts::FixedAt::No,
long_units: false,
space: true,
suffix: "",
allow_negative: false,
})
};
static ref NOW: DateTime<Utc> = Utc::now();
}
#[derive(Debug, Clone)]
pub struct UnInitialized;
#[derive(Debug, Clone)]
pub struct Shown;
#[derive(Debug, Clone)]
pub struct Hidden;
pub trait Visibility {}
impl Visibility for Shown {}
impl Visibility for Hidden {}
#[derive(Debug, Clone)]
pub struct Normal;
#[derive(Debug, Clone)]
pub struct GreyedOut;
#[derive(Debug, Clone)]
pub struct Title<S> {
title: gtk::Label,
state: S,
}
impl<S> Title<S> {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn set_title(&mut self, s: &str) {
self.title.set_text(s);
}
}
impl Title<Normal> {
fn new(title: gtk::Label) -> Self {
Title {
title,
state: Normal {},
}
}
}
impl From<Title<Normal>> for Title<GreyedOut> {
fn from(f: Title<Normal>) -> Self {
f.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
Title {
title: f.title,
state: GreyedOut {},
}
}
}
impl From<Title<GreyedOut>> for Title<Normal> {
fn from(f: Title<GreyedOut>) -> Self {
f.title
.get_style_context()
.map(|c| c.remove_class("dim-label"));
Title {
title: f.title,
state: Normal {},
}
}
}
#[derive(Debug, Clone)]
pub enum TitleMachine {
Normal(Title<Normal>),
GreyedOut(Title<GreyedOut>),
}
impl TitleMachine {
pub fn new(label: gtk::Label, is_played: bool) -> Self {
let m = TitleMachine::Normal(Title::<Normal>::new(label));
m.determine_state(is_played)
}
pub fn determine_state(self, is_played: bool) -> Self {
use self::TitleMachine::*;
match (self, is_played) {
(title @ Normal(_), false) => title,
(title @ GreyedOut(_), true) => title,
(Normal(val), true) => GreyedOut(val.into()),
(GreyedOut(val), false) => Normal(val.into()),
}
}
pub fn set_title(&mut self, s: &str) {
use self::TitleMachine::*;
match *self {
Normal(ref mut val) => val.set_title(s),
GreyedOut(ref mut val) => val.set_title(s),
}
}
}
#[derive(Debug, Clone)]
pub struct Usual;
#[derive(Debug, Clone)]
pub struct YearShown;
#[derive(Debug, Clone)]
pub struct Date<S> {
date: gtk::Label,
epoch: i64,
state: S,
}
impl<S> Date<S> {
fn into_usual(self, epoch: i64) -> Date<Usual> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: Usual {},
}
}
fn into_year_shown(self, epoch: i64) -> Date<YearShown> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: YearShown {},
}
}
}
impl Date<UnInitialized> {
fn new(date: gtk::Label, epoch: i64) -> Self {
let ts = Utc.timestamp(epoch, 0);
date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date,
epoch,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub enum DateMachine {
UnInitialized(Date<UnInitialized>),
Usual(Date<Usual>),
WithYear(Date<YearShown>),
}
impl DateMachine {
pub fn new(label: gtk::Label, epoch: i64) -> Self {
let m = DateMachine::UnInitialized(Date::<UnInitialized>::new(label, epoch));
m.determine_state(epoch)
}
pub fn determine_state(self, epoch: i64) -> Self {
use self::DateMachine::*;
let ts = Utc.timestamp(epoch, 0);
let is_old = !(NOW.year() == ts.year());
match (self, is_old) {
// Into Usual
(Usual(val), false) => Usual(val.into_usual(epoch)),
(WithYear(val), false) => Usual(val.into_usual(epoch)),
(UnInitialized(val), false) => Usual(val.into_usual(epoch)),
// Into Year Shown
(Usual(val), true) => WithYear(val.into_year_shown(epoch)),
(WithYear(val), true) => WithYear(val.into_year_shown(epoch)),
(UnInitialized(val), true) => WithYear(val.into_year_shown(epoch)),
}
}
}
#[derive(Debug, Clone)]
pub struct Duration<S: Visibility> {
// TODO: make duration and separator diff types
duration: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S: Visibility> Duration<S> {
// This needs a better name.
// TODO: make me mut
fn set_duration(&self, minutes: i64) {
self.duration.set_text(&format!("{} min", minutes));
}
}
impl Duration<Hidden> {
fn new(duration: gtk::Label, separator: gtk::Label) -> Self {
duration.hide();
separator.hide();
Duration {
duration,
separator,
state: Hidden {},
}
}
}
impl From<Duration<Hidden>> for Duration<Shown> {
fn from(f: Duration<Hidden>) -> Self {
f.duration.show();
f.separator.show();
Duration {
duration: f.duration,
separator: f.separator,
state: Shown {},
}
}
}
impl From<Duration<Shown>> for Duration<Hidden> {
fn from(f: Duration<Shown>) -> Self {
f.duration.hide();
f.separator.hide();
Duration {
duration: f.duration,
separator: f.separator,
state: Hidden {},
}
}
}
#[derive(Debug, Clone)]
pub enum DurationMachine {
Hidden(Duration<Hidden>),
Shown(Duration<Shown>),
}
impl DurationMachine {
pub fn new(duration: gtk::Label, separator: gtk::Label, seconds: Option<i32>) -> Self {
let m = DurationMachine::Hidden(Duration::<Hidden>::new(duration, separator));
m.determine_state(seconds)
}
pub fn determine_state(self, seconds: Option<i32>) -> Self {
match (self, seconds) {
(d @ DurationMachine::Hidden(_), None) => d,
(DurationMachine::Shown(val), None) => DurationMachine::Hidden(val.into()),
(DurationMachine::Hidden(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val)
} else {
val.set_duration(minutes);
DurationMachine::Shown(val.into())
}
}
(DurationMachine::Shown(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val.into())
} else {
val.set_duration(minutes);
DurationMachine::Shown(val)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct Size<S> {
size: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S> Size<S> {
fn set_size(self, s: &str) -> Size<Shown> {
self.size.set_text(s);
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_shown(self) -> Size<Shown> {
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
fn into_hidden(self) -> Size<Hidden> {
self.size.hide();
self.separator.hide();
Size {
size: self.size,
separator: self.separator,
state: Hidden {},
}
}
}
impl Size<UnInitialized> {
fn new(size: gtk::Label, separator: gtk::Label) -> Self {
size.hide();
separator.hide();
Size {
size,
separator,
state: UnInitialized {},
}
}
}
// pub trait Playable {}
// impl Playable for Download {}
// impl Playable for Play {}
#[derive(Debug, Clone)]
pub struct Download;
#[derive(Debug, Clone)]
pub struct Play;
#[derive(Debug, Clone)]
// FIXME: Needs better name.
// Should each button also has it's own type and machine?
pub struct DownloadPlay<S> {
play: gtk::Button,
download: gtk::Button,
state: S,
}
impl<S> DownloadPlay<S> {
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_playable(self) -> DownloadPlay<Play> {
self.play.show();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Play {},
}
}
fn into_fetchable(self) -> DownloadPlay<Download> {
self.play.hide();
self.download.show();
DownloadPlay {
play: self.play,
download: self.download,
state: Download {},
}
}
fn into_hidden(self) -> DownloadPlay<Hidden> {
self.play.hide();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Hidden {},
}
}
fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.download.connect_clicked(f)
}
fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.play.connect_clicked(f)
}
}
impl DownloadPlay<UnInitialized> {
fn new(play: gtk::Button, download: gtk::Button) -> Self {
play.hide();
download.hide();
DownloadPlay {
play,
download,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Progress<S> {
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
state: S,
}
impl<S> Progress<S> {
fn into_shown(self) -> Progress<Shown> {
self.bar.show();
self.cancel.show();
self.local_size.show();
self.prog_separator.show();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Shown {},
}
}
fn into_hidden(self) -> Progress<Hidden> {
self.bar.hide();
self.cancel.hide();
self.local_size.hide();
self.prog_separator.hide();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Hidden {},
}
}
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.local_size.set_text(local_size);
self.bar.set_fraction(fraction);
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
self.cancel.connect_clicked(move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
})
}
}
impl Progress<UnInitialized> {
fn new(
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
bar.hide();
cancel.hide();
local_size.hide();
prog_separator.hide();
Progress {
bar,
cancel,
local_size,
prog_separator,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Media<X, Y, Z> {
dl: DownloadPlay<X>,
size: Size<Y>,
progress: Progress<Z>,
}
type New<Y> = Media<Download, Y, Hidden>;
type Playable<Y> = Media<Play, Y, Hidden>;
type InProgress = Media<Hidden, Shown, Shown>;
type MediaUnInitialized = Media<UnInitialized, UnInitialized, UnInitialized>;
impl From<New<Shown>> for InProgress {
fn from(f: New<Shown>) -> Self {
f.into_progress()
}
}
impl From<New<Hidden>> for InProgress {
fn from(f: New<Hidden>) -> Self {
f.into_progress()
}
}
impl From<Playable<Shown>> for InProgress {
fn from(f: Playable<Shown>) -> Self {
f.into_progress()
}
}
impl From<Playable<Hidden>> for InProgress {
fn from(f: Playable<Hidden>) -> Self {
f.into_progress()
}
}
impl<Y: Visibility> From<Playable<Y>> for New<Y> {
fn from(f: Playable<Y>) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size,
progress: f.progress,
}
}
}
impl<Y: Visibility> From<New<Y>> for Playable<Y> {
fn from(f: New<Y>) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size,
progress: f.progress,
}
}
}
impl From<MediaUnInitialized> for New<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl From<MediaUnInitialized> for Playable<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl<X, Y, Z> Media<X, Y, Z> {
fn set_size(self, s: &str) -> Media<X, Shown, Z> {
Media {
dl: self.dl,
size: self.size.set_size(s),
progress: self.progress,
}
}
fn hide_size(self) -> Media<X, Hidden, Z> {
Media {
dl: self.dl,
size: self.size.into_hidden(),
progress: self.progress,
}
}
fn into_new(self, size: &str) -> New<Shown> {
Media {
dl: self.dl.into_fetchable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
fn into_playable(self, size: &str) -> Playable<Shown> {
Media {
dl: self.dl.into_playable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
}
impl<X, Z> Media<X, Shown, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.into_shown(),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, Hidden, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, UnInitialized, Z> {
fn into_progress(self, size: Option<String>) -> InProgress {
if let Some(s) = size {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size(&s),
progress: self.progress.into_shown(),
}
} else {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
}
impl InProgress {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.progress.update_progress(local_size, fraction)
}
}
#[derive(Debug, Clone)]
pub enum ButtonsState {
New(Media<Download, Shown, Hidden>),
NewWithoutSize(Media<Download, Hidden, Hidden>),
Playable(Media<Play, Shown, Hidden>),
PlayableWithoutSize(Media<Play, Hidden, Hidden>),
}
impl ButtonsState {
pub fn determine_state(self, size: Option<String>, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
match (self, size, is_downloaded) {
// From whatever to New
(New(m), Some(s), false) => New(m.into_new(&s)),
(Playable(m), Some(s), false) => New(m.into_new(&s)),
(NewWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
// From whatever to Playable
(New(m), Some(s), true) => Playable(m.into_playable(&s)),
(Playable(m), Some(s), true) => Playable(m.into_playable(&s)),
(NewWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
(PlayableWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
// From whatever to NewWithoutSize
(New(m), None, false) => NewWithoutSize(m.hide_size()),
(Playable(m), None, false) => NewWithoutSize(Media::from(m).hide_size()),
(b @ NewWithoutSize(_), None, false) => b,
(PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into()),
// From whatever to PlayableWithoutSize
(New(m), None, true) => PlayableWithoutSize(Media::from(m).hide_size()),
(Playable(m), None, true) => PlayableWithoutSize(m.hide_size()),
(NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into()),
(b @ PlayableWithoutSize(_), None, true) => b,
// _ => unimplemented!()
}
}
fn into_progress(self) -> InProgress {
use self::ButtonsState::*;
match self {
New(m) => m.into(),
Playable(m) => m.into(),
NewWithoutSize(m) => m.into(),
PlayableWithoutSize(m) => m.into(),
}
}
fn set_size(self, size: Option<String>) -> Self {
use self::ButtonsState::*;
match (self, size) {
(New(m), Some(s)) => New(m.set_size(&s)),
(New(m), None) => NewWithoutSize(m.hide_size()),
(Playable(m), Some(s)) => Playable(m.set_size(&s)),
(Playable(m), None) => PlayableWithoutSize(m.hide_size()),
(bttn @ NewWithoutSize(_), None) => bttn,
(bttn @ PlayableWithoutSize(_), None) => bttn,
(NewWithoutSize(m), Some(s)) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s)) => Playable(m.into_playable(&s)),
}
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.download_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.download_connect_clicked(f),
Playable(ref val) => val.dl.download_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.play_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.play_connect_clicked(f),
Playable(ref val) => val.dl.play_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.play_connect_clicked(f),
}
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.progress.cancel_connect_clicked(prog),
NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
Playable(ref val) => val.progress.cancel_connect_clicked(prog),
PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
}
#[derive(Debug, Clone)]
pub enum MediaMachine {
UnInitialized(Media<UnInitialized, UnInitialized, UnInitialized>),
Initialized(ButtonsState),
InProgress(Media<Hidden, Shown, Shown>),
}
impl MediaMachine {
#[cfg_attr(feature = "cargo-clippy", allow(too_many_arguments))]
pub fn new(
play: gtk::Button,
download: gtk::Button,
bar: gtk::ProgressBar,
cancel: gtk::Button,
total_size: gtk::Label,
local_size: gtk::Label,
separator: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
let dl = DownloadPlay::<UnInitialized>::new(play, download);
let progress = Progress::<UnInitialized>::new(bar, cancel, local_size, prog_separator);
let size = Size::<UnInitialized>::new(total_size, separator);
MediaMachine::UnInitialized(Media { dl, progress, size })
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.download_connect_clicked(f),
Initialized(ref val) => val.download_connect_clicked(f),
InProgress(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.play_connect_clicked(f),
Initialized(ref val) => val.play_connect_clicked(f),
InProgress(ref val) => val.dl.play_connect_clicked(f),
}
}
pub fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.progress.cancel_connect_clicked(prog),
Initialized(ref val) => val.cancel_connect_clicked(prog),
InProgress(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
pub fn determine_state(self, bytes: Option<i32>, is_active: bool, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
use self::MediaMachine::*;
match (self, size_helper(bytes), is_downloaded, is_active) {
(UnInitialized(m), s, _, true) => InProgress(m.into_progress(s)),
// Into New
(UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
(UnInitialized(m), None, false, false) => Initialized(NewWithoutSize(m.into())),
// Into Playable
(UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
(UnInitialized(m), None, true, false) => Initialized(PlayableWithoutSize(m.into())),
(Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)),
(Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()),
(i @ InProgress(_), _, _, _) => i,
}
}
pub fn set_size(self, bytes: Option<i32>) -> Self {
use self::MediaMachine::*;
let size = size_helper(bytes);
match (self, size) {
(Initialized(bttn), s) => Initialized(bttn.set_size(s)),
(InProgress(val), Some(s)) => InProgress(val.set_size(&s)),
(n @ InProgress(_), None) => n,
(n @ UnInitialized(_), _) => n,
}
}
pub fn update_progress(&mut self, local_size: &str, fraction: f64) {
use self::MediaMachine::*;
match *self {
Initialized(_) => (),
UnInitialized(_) => (),
InProgress(ref mut val) => val.update_progress(local_size, fraction),
}
}
}
#[inline]
fn size_helper(bytes: Option<i32>) -> Option<String> {
let s = bytes?;
if s == 0 {
return None;
}
s.file_size(SIZE_OPTS.clone()).ok()
}
+2
View File
@@ -1,5 +1,7 @@
mod show;
mod episode;
mod episode_states;
pub use self::episode::EpisodeWidget;
pub use self::show::ShowWidget;
pub use self::show::mark_all_watched;
+64 -22
View File
@@ -1,20 +1,19 @@
use dissolve;
use failure::Error;
// use glib;
use gtk;
use gtk::prelude::*;
use html2pango::markup_from_raw;
use open;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use hammond_data::utils::{delete_show, replace_extra_spaces};
use app::Action;
use utils::get_pixbuf_from_path;
use utils::set_image_from_path;
use widgets::episode::episodes_listbox;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::thread;
#[derive(Debug, Clone)]
pub struct ShowWidget {
@@ -62,6 +61,8 @@ impl ShowWidget {
}
pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
WidgetExt::set_name(&self.container, &pd.id().to_string());
@@ -72,7 +73,7 @@ impl ShowWidget {
}
}));
self.setup_listbox(&pd, sender.clone());
self.setup_listbox(pd.clone(), sender.clone());
self.set_description(pd.description());
if let Err(err) = self.set_cover(pd.clone()) {
@@ -88,26 +89,35 @@ impl ShowWidget {
error!("Error: {}", err);
}
});
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
let episodes = self.episodes.clone();
mark_all.connect_clicked(clone!(pd, sender => move |_| {
on_played_button_clicked(
pd.clone(),
&episodes,
sender.clone()
)
}));
self.settings.set_popover(&show_menu);
}
/// Populate the listbox with the shows episodes.
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
fn setup_listbox(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
let listbox = episodes_listbox(pd, sender.clone());
listbox.ok().map(|l| self.episodes.add(&l));
}
/// Set the show cover.
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
let image = get_pixbuf_from_path(&pd.into(), 128)?;
self.cover.set_from_pixbuf(&image);
Ok(())
set_image_from_path(&self.cover, Arc::new(pd.into()), 128)
}
/// 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));
}
/// Set scrolled window vertical adjustment.
@@ -124,13 +134,7 @@ fn on_unsub_button_clicked(
// 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);
}
});
sender.send(Action::RemoveShow(pd))?;
sender.send(Action::HeaderBarNormal)?;
sender.send(Action::ShowShowsAnimated)?;
@@ -141,9 +145,47 @@ fn on_unsub_button_clicked(
Ok(())
}
#[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::Frame, sender: Sender<Action>) {
if dim_titles(episodes).is_none() {
error!("Something went horribly wrong when dimming the titles.");
warn!("RUN WHILE YOU STILL CAN!");
}
sender.send(Action::MarkAllPlayerNotification(pd)).unwrap();
}
pub fn mark_all_watched(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?;
sender.send(Action::RefreshWidget)?;
sender.send(Action::RefreshWidgetIfVis)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
}
// Ideally if we had a custom widget this would have been as simple as:
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
// But now I can't think of a better way to do it than hardcoding the title
// position relative to the EpisodeWidget container gtk::Box.
fn dim_titles(episodes: &gtk::Frame) -> Option<()> {
let listbox = episodes
.get_children()
.remove(0)
.downcast::<gtk::ListBox>()
.ok()?;
let children = listbox.get_children();
for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let foo = container
.get_children()
.remove(0)
.downcast::<gtk::Box>()
.ok()?;
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let baz = bar.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let title = baz.get_children().remove(0).downcast::<gtk::Label>().ok()?;
title.get_style_context().map(|c| c.add_class("dim-label"));
}
Some(())
}
+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/alatiera/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.1',
license: 'GPLv3',
)
+5 -1
View File
@@ -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",
@@ -29,7 +33,7 @@
},
"modules" : [
{
"name" : "Hammond",
"name" : "hammond",
"buildsystem" : "meson",
"sources" : [
{
+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])