283 Commits
Author SHA1 Message Date
Jordan Petridis 008404ffb3 Update Changelog. 2018-07-04 18:40:03 +03:00
Jordan Petridis 2b6cca6bab Version bump. 2018-07-04 17:26:56 +03:00
Jordan Petridis fe968e19c0 Update Changelog. 2018-07-04 16:39:19 +03:00
Jordan Petridis 479498d8be EpisodeMinimal: add lenght as well. 2018-07-03 16:38:08 +03:00
Jordan Petridis af9669acd0 Add a bash script to run the test-suite. 2018-07-01 00:55:57 +03:00
Jordan Petridis d2eb98f859 Update gitignore file. 2018-07-01 00:54:28 +03:00
Jordan Petridis ae11084f48 Merge branch 'db-cleanup' into 'master'
Database cleanup

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

Closes #38

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #8

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

This also avoid the constrain for Clone on T.
2018-04-10 16:57:08 +00:00
Jordan Petridis ed80605755 Move the lazy_load logic to a Generic function. 2018-04-10 16:57:08 +00:00
Jordan Petridis cc84a4637d EpisodesListBox: Do not block while fetching episode backlog. 2018-04-10 16:57:08 +00:00
Jordan Petridis 701b759ba2 EpisodesListBox: Add each widget lazyly. 2018-04-10 16:57:08 +00:00
Jordan Petridis 9cb2782ef9 ShowWidget: Initial Lazier evaluation of the widgets. 2018-04-10 16:57:08 +00:00
Jordan Petridis 5cd0a3c451 Fix the things I broke in b74dbb74bb
Someone really needs to restrict my access to anything that involves
transistors when I am sleep deprived.
2018-04-10 07:01:55 +03:00
Jordan Petridis b74dbb74bb h-data: Remove rel attributes from <a> tags when sanitizing html.
They are invalid in `pango` markup so theres no reason they
should are not needed. Also add some paranoid .trim() calls.
It returnes a &str slice so it's cheap.
2018-04-10 06:31:51 +03:00
83 changed files with 5645 additions and 4156 deletions
+1
View File
@@ -7,3 +7,4 @@ resources.gresource
_build
vendor/
.criterion
org.gnome.*.json~
+62 -25
View File
@@ -1,17 +1,31 @@
stages:
- test
- lint
- review
variables:
BUNDLE: "hammond-dev.flatpak"
.cargo_cache_template: &cargo_cache
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
.cargo_test_template: &cargo_test
stage: test
# variables:
# RUSTFLAGS: "-C link-dead-code"
# RUST_BACKTRACE: "FULL"
variables:
RUSTFLAGS: "--cfg rayon_unstable"
RUST_BACKTRACE: "FULL"
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson libgstreamer1.0-dev
- mkdir -p .cargo_cache
# Only stuff inside the repo directory can be cached
@@ -25,22 +39,14 @@ stages:
- cargo build
- cargo test -- --test-threads=1
- cargo test -- --test-threads=1 --ignored
<<: *cargo_cache
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
rust:stable:
.rust:stable:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
rust:nightly:
.rust:nightly:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
@@ -49,11 +55,11 @@ rust:nightly:
- web
flatpak:
image: registry.gitlab.com/alatiera/gnome-nightly-oci/rust-bundle:latest
image: registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.28
stage: test
script:
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/alatiera/Hammond/issues/55
# https://gitlab.gnome.org/World/hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
@@ -64,15 +70,15 @@ flatpak:
- flatpak build-export repo app
# Create a flatpak bundle
- flatpak build-bundle repo hammond-dev.flatpak org.gnome.Hammond
- flatpak build-bundle repo ${BUNDLE} org.gnome.Hammond
# Run the tests
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored
artifacts:
paths:
- hammond-dev.flatpak
expire_in: 2 days
- $BUNDLE
expire_in: 30 days
cache:
# JOB_NAME - Each job will have it's own cache
@@ -83,6 +89,34 @@ flatpak:
- .flatpak-builder/cache/
- target/
review:
stage: review
dependencies:
- flatpak
script:
- echo "Generating flatpak deployment"
artifacts:
paths:
- $BUNDLE
expire_in: 30 days
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE}
on_stop: stop_review
except:
- master@World/hammond
stop_review:
stage: review
script:
- echo "Stopping flatpak deployment"
when: manual
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
except:
- master@World/hammond
# Configure and run rustfmt on nightly
# Exits and builds fails if on bad format
rustfmt:
@@ -91,17 +125,20 @@ rustfmt:
script:
- rustc -Vv && cargo -Vv
- cargo fmt --version
- cargo fmt --all -- --write-mode=diff
- cargo fmt --all -- --check
# Configure and run clippy on nightly
# Only fails on errors atm.
clippy:
image: "rustlang/rust:nightly"
.clippy:
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly"
stage: lint
variables:
RUSTFLAGS: "--cfg rayon_unstable"
CARGO_HOME: ".cargo_cache"
script:
- rustc --version && cargo --version
- cargo install clippy --force
- cargo clippy --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all
when: manual
<<: *cargo_cache
+39 -6
View File
@@ -1,9 +1,42 @@
Detailed description of the issue. Put as much information as you can, potentially
with images showing the issue.
# Steps to reproduce
<!--
Explain in detail the steps on how the issue can be reproduced.
-->
1.
2.
3.
Steps to reproduce:
Reproducible in:
<!--
Please test if the issue was already fixed in the unstable version of the app.
For that, follow these steps:
1. Make sure Flatpak is installed or install it following these steps https://flatpak.org/setup
2. Install the unstable version of the app following this link
https://gitlab.gnome.org/GNOME/gnome-apps-nightly/raw/master/nautilus.flatpakref and save
it as a ".flatpakref" file
1. Open Hammond
2. Do an action
3. ...
If these steps failed, write in 'Other' the distribution youre using and
the version of the app.
-->
- Flatpak unstable: (yes or no) <!-- Write "yes" or "no" after the semicolon. -->
- Other:
# Current behavior
<!-- Describe the current behavior. -->
# Expected behavior
<!-- Describe the expected behavior. -->
# Additional information
<!--
Provide more information that could be relevant.
If the issue is a crash, provide a stack trace following the steps in:
https://wiki.gnome.org/Community/GettingInTouch/Bugzilla/GettingTraces
-->
<!-- Ignore the text under this line. -->
/label ~"Bug"
+41
View File
@@ -0,0 +1,41 @@
# Current problems
<!--
What are the problems that the current project has?
For example:
* User cannot use the keyboard to perform most common actions
or
* User cannot see documents from cloud services
-->
# Goals & use cases
<!--
What are the use cases that this proposal will cover? What are the end goals?
For example:
* User needs to share a file with their friends.
or
* It should be easy to edit a picture within the app.
-->
# Requirements
<!--
What does the solution needs to ensure for being succesful?
For example:
* Work on small form factors and touch
or
* Use the Meson build system and integrate with it
-->
# Relevant art
<!--
Is there any product that has implemented something similar? Put links to other
projects, pictures, links to other code, etc.
-->
# Proposal & plan
<!-- What's the solution and how should be achieved? It can be split in smaller
tasks of minimum change, so they can be delivered across several releases. -->
/label ~"Epic"
+17 -10
View File
@@ -1,17 +1,24 @@
Detailed description of the feature. Put as much information as you can.
### Use cases
<!--
Describe what problem(s) the user is experiencing and that this request
is trying to solve.
-->
Proposed Mockups:
(Add mockups of the proposed feature)
### Desired behavior
<!-- Describe the desired functionality. -->
## Design Tasks
* [ ] design tasks
### Benefits of the solution
<!-- List the possible benefits of the solution and how it fits in the project. -->
## Development Tasks
* [ ] development tasks
### Possible drawbacks
<!--
Describe possible drawbacks of the feature and list how it could affect
the project i.e. UI discoverability, complexity, impact in more or less
number of users, etc.
-->
## QA Tasks
* [ ] qa (quality assurance) tasks
<!-- Ignore the text under this line. -->
/label ~"Feature"
+99 -38
View File
@@ -5,53 +5,114 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added:
* 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)
### Changed:
### Fixed:
### Removed:
## [0.4.0] - 2018-07-04
### Added:
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/hammond/merge_requests/33)
### Changed:
- The `FileChooser` of the OPML import was changed to use the `FileChooserNative` widget/API. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/hammond/merge_requests/33)
- The `EpisdeWidget` was refactored.
[!38](https://gitlab.gnome.org/World/hammond/merge_requests/38)
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
- An embeded audio player was implemented!
[!40](https://gitlab.gnome.org/World/hammond/merge_requests/40)
- Various Database changes.
[!41](https://gitlab.gnome.org/World/hammond/merge_requests/41)
### Fixed:
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/hammond/merge_requests/37)
## [0.3.4] - 2018-05-20
### Fixed:
- Flatpak can now access the Home folder. This fixes the OPML import feature from
not being able to access any file.
## [0.3.3] - 2018-05-19
### Added:
- Initial functionality for importing shows from an OPML file was implemented.
- ShowsView now rembmers the vertical alignment of the scrollbar between refreshes. 4d2b64e79d8518454b3677612664cd32044cf837
### Changed:
- Minimum `rustc` version requirment was bumped to `1.26`
- Some animations should be smoother now. 7d598bb1d08b05fd5ab532657acdad967c0afbc3
- InAppNotification now can be used to propagate some erros to the user. 7035fe05c4741b3e7ccce6827f72766226d5fc0a and 118dac5a1ab79c0b4ebe78e88256a4a38b138c04
### Fixed:
- Fixed a of by one bug in the `ShowsView` where the last show was never shown. bd12b09cbc8132fd39a266fd091e24bc6c3c040f
## [0.3.2] - 2018-05-07
### Added:
- Vies now have a new fancy scrolling animation when they are refereshed.
### Changed:
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/World/hammond/issues/7)
- Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/World/hammond/issues/25)
- `Rayon` Threadpools are now used instead of unlimited one-off threads.
- `EpisdeWidget`s are now loaded asynchronously accross views.
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
- `ShowWidget` layout was changed 9a5cc1595d982f3232ee7595b83b6512ac8f6c88
- `ShowWidget` Description is inside a scrolled window now
### Fixed:
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/hammond/issues/57)
- Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
### Removed:
- Removed the custom configuration file and replaced instructions to just use meson. 1f1d4af8ba7db8f56435d13a1c191ecff3d4a85b
## [0.3.1] - 2018-03-28
### Added:
- Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/World/hammond/issues/47)
- Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/World/hammond/issues/49)
- Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/World/hammond/issues/50)
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/World/hammond/merge_requests/22) [!23](https://gitlab.gnome.org/World/hammond/merge_requests/23)
- Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/World/hammond/issues/44)
* Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/alatiera/Hammond/issues/47)
* Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/alatiera/Hammond/issues/49)
* EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/18)
* Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/alatiera/Hammond/issues/35)
* Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/alatiera/Hammond/issues/52)
* Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/alatiera/Hammond/issues/53)
* Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/alatiera/Hammond/issues/50)
* Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/22) [!23](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/23)
* Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/alatiera/Hammond/issues/44)
### Changed:
- EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/World/hammond/merge_requests/18)
- Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/World/hammond/issues/35)
- Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/World/hammond/issues/53)
### Fixed:
- Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/World/hammond/issues/52)
## [0.3.0] - 2018-02-11
* Tobias Bernard Redesigned the whole Gtk+ client.
* Complete re-write of hammond-data and hammond-gtk modules.
* Error handling for all crates was migrated from error-chain to Failure.
* Hammond-data now uses futures to parse feeds.
* Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
- Tobias Bernard Redesigned the whole Gtk+ client.
- Complete re-write of hammond-data and hammond-gtk modules.
- Error handling for all crates was migrated from error-chain to Failure.
- Hammond-data now uses futures to parse feeds.
- Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
## [0.2.0] - 2017-11-28
* Database Schema Breaking Changes.
* Added url sanitization. #4.
* Reworked and refactored of the hammond-data API.
* Added some more unit tests
* Documented hammond-data public API.
- Database Schema Breaking Changes.
- Added url sanitization. #4.
- Reworked and refactored of the hammond-data API.
- Added some more unit tests
- Documented hammond-data public API.
## [0.1.1] - 2017-11-13
* Added appdata.xml file
- Added appdata.xml file
## [0.1.0] - 2017-11-13
Initial Release
- Initial Release
+2 -2
View File
@@ -12,7 +12,7 @@ Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOf
## Source repository
Hammond's main source repository is at gitlab.gnome.org. You can view
the web interface [here](https://gitlab.gnome.org/alatiera/hammond)
the web interface [here](https://gitlab.gnome.org/World/hammond)
Development happens in the master branch.
@@ -51,7 +51,7 @@ In order to run the test suite use the following: `cargo test -- --test-threads=
# Issues, issues and more issues!
There are many ways you can contribute to Hammond, and all of them involve creating issues
in [Hammond issue tracker](https://gitlab.gnome.org/alatiera/Hammond/issues). This is the entry point for your contribution.
in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/issues). This is the entry point for your contribution.
To create an effective and high quality ticket, try to put the following information on your
ticket:
Generated
+1005 -468
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,4 +7,3 @@ members = [
[profile.release]
debug = false
+35 -27
View File
@@ -1,13 +1,6 @@
# Hammond
## A Podcast Client for the GNOME Desktop written in Rust.
[![pipeline status](https://gitlab.gnome.org/alatiera/Hammond/badges/master/pipeline.svg)](https://gitlab.gnome.org/alatiera/Hammond/commits/master)
[![Dependency Status](https://dependencyci.com/github/alatiera/Hammond/badge)](https://dependencyci.com/github/alatiera/Hammond)
### Features
* TBA
### A Podcast Client for GNOME written in Rust.
![episdes_view](./screenshots/episodes_view.png)
![shows_view](./screenshots/shows_view.png)
@@ -22,12 +15,13 @@ Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
## Broken Feeds
Found a feed that does not work in Hammond?
Please [open an issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
## Getting in Touch
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
want to discuss design or simply hang out, please join us in `#hammond` on
[irc.gnome.org.][irc] or [matrix][matrix].
Note:
@@ -53,26 +47,30 @@ flatpak --user install gnome-nightly org.gnome.Sdk org.gnome.Platform
# Install the required rust-stable extension from flathub
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
flatpak build-bundle repo hammond org.gnome.Hammond
```
To install the resulting flatpak you can do:
```bash
flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
flatpak install --user --bundle hammond.flatpak
```
### Building from soure
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
git clone https://gitlab.gnome.org/World/hammond.git
cd hammond/
./configure --prefix=/usr/local
make && sudo make install
meson --prefix=/usr build
ninja -C build
sudo ninja -C build install
```
**Additional:**
You can run `sudo make uninstall` for removal
#### Dependencies
* Rust stable 1.22 or later along with cargo.
* Rust stable 1.27 or later along with cargo.
* Gtk+ 3.22 or later
* Gstreamer 1.12 or later
* Meson
* A network connection
@@ -81,15 +79,23 @@ Offline build are possible too, but [`cargo-vendor`][vendor] would have to be se
**Debian/Ubuntu**
```sh
apt-get update -yqq
apt-get install -yqq --no-install-recommends build-essential
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
apt-get install -yqq cargo rustc libsqlite3-dev libssl-dev libgtk-3-dev meson \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
gstreamer1.0-libav libgstrtspserver-1.0-dev ibgstreamer-plugins-bad1.0-dev
```
**Fedora**
```sh
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson \
gstreamer1-devel gstreamer1-plugins-base-tools gstreamer1-devel-docs \
gstreamer1-plugins-base-devel gstreamer1-plugins-base-devel-docs \
gstreamer1-plugins-good gstreamer1-plugins-good-extras \
gstreamer1-plugins-bad-free gstreamer1-plugins-bad-free-devel \
gstreamer1-plugins-bad-free-extras
```
If you happen to build it on other distributions please let me know the names
@@ -101,11 +107,11 @@ There alot of thins yet to be done.
If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines].
You can start by taking a look at [Issues](https://gitlab.gnome.org/alatiera/Hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
You can start by taking a look at [Issues](https://gitlab.gnome.org/World/hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/World/hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
[contribution-guidelines]: https://gitlab.gnome.org/alatiera/Hammond/blob/master/CONTRIBUTING.md
[contribution-guidelines]: https://gitlab.gnome.org/World/hammond/blob/master/CONTRIBUTING.md
## Overview
@@ -125,7 +131,7 @@ $ tree -d
│   ├── resources # GResources folder
│   │   └── gtk # Contains the glade.ui files.
│   └── src
│   ├── views # Contains the Empty, Episodes and Shows view.
│   ├── stacks # Contains the gtk Stacks that hold all the different views.
│   └── widgets # Contains custom widgets such as Show and Episode.
```
@@ -143,4 +149,6 @@ We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/App
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
[vendor]: https://github.com/alexcrichton/cargo-vendor
[irc]: irc://irc.gnome.org/#hammond
[matrix]: https://matrix.to/#/#hammond:matrix.org
+2 -9
View File
@@ -4,12 +4,11 @@
## Priorities
- [ ] Unplayed Only and Downloaded only view.
- [ ] OPML import/export // Probably need to create a crate.
## Second
- [ ] Make use of file metadas, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
- [ ] Make use of file metadas?, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
- [ ] Episode queue
- [ ] Embedded player
- [ ] MPRIS integration
@@ -19,14 +18,8 @@
- [ ] Download Queue
- [ ] Ability to Stream content on demand
- [ ] soundcloud and itunes feeds // [This](http://getrssfeed.com) seems intresting.
- [ ] rss feeds from soundcloud urls? // [This](http://getrssfeed.com) seems intresting.
- [ ] Integrate with Itunes API for various crap?
- [ ] YoutubeFeeds?
## Rest Tasks
**Would be nice:**
- [ ] Make Podcast cover fetchng and loading not block the execution of the program at startup.
- [ ] Lazy evaluate episode loading based on the show_widget's scrolling.
Vendored
-186
View File
@@ -1,186 +0,0 @@
#!/bin/bash
# Adapted from:
# https://gitlab.gnome.org/danigm/libgepub/blob/27f0d374e0c8f6fa972dbd111d4ce0c0f3096914/configure_meson
# configure script adapter for Meson
# Based on build-api: https://github.com/cgwalters/build-api
# Copyright 2010, 2011, 2013 Colin Walters <walters@verbum.org>
# Copyright 2016, 2017 Emmanuele Bassi
# Copyright 2017 Iñigo Martínez <inigomartinez@gmail.com>
# Licensed under the new-BSD license (http://www.opensource.org/licenses/bsd-license.php)
# Build API variables:
# Little helper function for reading args from the commandline.
# it automatically handles -a b and -a=b variants, and returns 1 if
# we need to shift $3.
read_arg() {
# $1 = arg name
# $2 = arg value
# $3 = arg parameter
local rematch='^[^=]*=(.*)$'
if [[ $2 =~ $rematch ]]; then
read "$1" <<< "${BASH_REMATCH[1]}"
else
read "$1" <<< "$3"
# There is no way to shift our callers args, so
# return 1 to indicate they should do it instead.
return 1
fi
}
sanitycheck() {
# $1 = arg name
# $1 = arg command
# $2 = arg alternates
local cmd=$( which $2 2>/dev/null )
if [ -x "$cmd" ]; then
read "$1" <<< "$cmd"
return 0
fi
test -z $3 || {
for alt in $3; do
cmd=$( which $alt 2>/dev/null )
if [ -x "$cmd" ]; then
read "$1" <<< "$cmd"
return 0
fi
done
}
echo -e "\e[1;31mERROR\e[0m: Command '$2' not found"
exit 1
}
checkoption() {
# $1 = arg
option="${1#*--}"
action="${option%%-*}"
name="${option#*-}"
if [ ${default_options[$name]+_} ]; then
case "$action" in
enable) meson_options[$name]=true;;
disable) meson_options[$name]=false;;
*) echo -e "\e[1;33mINFO\e[0m: Ignoring unknown action '$action'";;
esac
else
echo -e "\e[1;33mINFO\e[0m: Ignoring unknown option '$option'"
fi
}
echooption() {
# $1 = option
if [ ${meson_options[$1]+_} ]; then
echo ${meson_options[$1]}
elif [ ${default_options[$1]+_} ]; then
echo ${default_options[$1]}
fi
}
sanitycheck MESON 'meson'
sanitycheck MESONTEST 'mesontest'
sanitycheck NINJA 'ninja' 'ninja-build'
declare -A meson_options
while (($# > 0)); do
case "${1%%=*}" in
--prefix) read_arg prefix "$@" || shift;;
--bindir) read_arg bindir "$@" || shift;;
--sbindir) read_arg sbindir "$@" || shift;;
--libexecdir) read_arg libexecdir "$@" || shift;;
--datarootdir) read_arg datarootdir "$@" || shift;;
--datadir) read_arg datadir "$@" || shift;;
--sysconfdir) read_arg sysconfdir "$@" || shift;;
--libdir) read_arg libdir "$@" || shift;;
--mandir) read_arg mandir "$@" || shift;;
--includedir) read_arg includedir "$@" || shift;;
*) checkoption $1;;
esac
shift
done
# Defaults
test -z ${prefix} && prefix="/usr/local"
test -z ${bindir} && bindir=${prefix}/bin
test -z ${sbindir} && sbindir=${prefix}/sbin
test -z ${libexecdir} && libexecdir=${prefix}/bin
test -z ${datarootdir} && datarootdir=${prefix}/share
test -z ${datadir} && datadir=${datarootdir}
test -z ${sysconfdir} && sysconfdir=${prefix}/etc
test -z ${libdir} && libdir=${prefix}/lib
test -z ${mandir} && mandir=${prefix}/share/man
test -z ${includedir} && includedir=${prefix}/include
# The source directory is the location of this file
srcdir=$(dirname $0)
# The build directory is the current location
builddir=`pwd`
# If we're calling this file from the source directory then
# we automatically create a build directory and ensure that
# both Meson and Ninja invocations are relative to that
# location
if [[ -f "${builddir}/meson.build" ]]; then
mkdir -p _build
builddir="${builddir}/_build"
NINJA_OPT="-C ${builddir}"
fi
# Wrapper Makefile for Ninja
cat > Makefile <<END
# Generated by configure; do not edit
all: rebuild
${NINJA} ${NINJA_OPT}
rebuild:
rm -f ${builddir}/hammond
install:
DESTDIR="\$(DESTDIR)" ${NINJA} ${NINJA_OPT} install
uninstall:
${NINJA} ${NINJA_OPT} uninstall
release:
${NINJA} ${NINJA_OPT} release
check:
${MESONTEST} ${NINJA_OPT}
END
echo "
hammond
=======
meson: ${MESON}
ninja: ${NINJA}
prefix: ${prefix}
Now type 'make' to build
"
cmd_options=""
for key in "${!meson_options[@]}"; do
cmd_options="$cmd_options -Denable-$key=${meson_options[$key]}"
done
exec ${MESON} \
--prefix=${prefix} \
--libdir=${libdir} \
--libexecdir=${libexecdir} \
--datadir=${datadir} \
--sysconfdir=${sysconfdir} \
--bindir=${bindir} \
--includedir=${includedir} \
--mandir=${mandir} \
${cmd_options} \
${builddir} \
${srcdir}
+13 -11
View File
@@ -6,19 +6,20 @@ workspace = "../"
[dependencies]
ammonia = "1.1.0"
chrono = "0.4.1"
chrono = "0.4.4"
derive_builder = "0.5.1"
itertools = "0.7.8"
lazy_static = "1.0.0"
log = "0.4.1"
lazy_static = "1.0.1"
log = "0.4.2"
rayon = "1.0.1"
rayon-futures = "0.1.0"
rfc822_sanitizer = "0.3.3"
rss = "1.4.0"
rss = "1.5.0"
url = "1.7.0"
xdg = "2.1.0"
xml-rs = "0.8.0"
futures = "0.1.21"
hyper = "0.11.24"
tokio-core = "0.1.16"
hyper = "0.11.27"
tokio-core = "0.1.17"
hyper-tls = "0.1.3"
native-tls = "0.1.5"
num_cpus = "1.8.0"
@@ -27,17 +28,18 @@ failure_derive = "0.1.1"
[dependencies.diesel]
features = ["sqlite", "r2d2"]
version = "1.1.1"
version = "1.3.2"
[dependencies.diesel_migrations]
features = ["sqlite"]
version = "1.1.0"
version = "1.3.0"
[dev-dependencies]
rand = "0.4.2"
rand = "0.5.2"
tempdir = "0.3.7"
criterion = "0.2.2"
criterion = "0.2.3"
pretty_assertions = "0.5.1"
maplit = "1.0.1"
[[bench]]
name = "bench"
+5 -24
View File
@@ -1,3 +1,5 @@
#![allow(unused)]
#[macro_use]
extern crate criterion;
use criterion::Criterion;
@@ -16,10 +18,10 @@ extern crate rss;
// use futures::future::*;
use tokio_core::reactor::Core;
use hammond_data::FeedBuilder;
use hammond_data::Source;
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
use hammond_data::FeedBuilder;
use hammond_data::Source;
// use hammond_data::errors::*;
use std::io::BufReader;
@@ -56,22 +58,6 @@ static FEEDS: &[(&[u8], &str)] = &[
(STARS, STARS_URL),
];
// This is broken and I don't know why.
fn bench_pipeline(c: &mut Criterion) {
truncate_db().unwrap();
FEEDS.iter().for_each(|&(_, url)| {
Source::from_url(url).unwrap();
});
c.bench_function("pipline", move |b| {
b.iter(|| {
let sources = hammond_data::dbqueries::get_sources().unwrap();
pipeline::run(sources, true).unwrap();
})
});
truncate_db().unwrap();
}
fn bench_index_large_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://www.greaterthancode.com/feed/podcast";
@@ -114,10 +100,5 @@ fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap();
}
criterion_group!(
benches,
bench_pipeline,
bench_index_large_feed,
bench_index_small_feed
);
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed);
criterion_main!(benches);
+6
View File
@@ -0,0 +1,6 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
patch_file = "src/schema.patch"
@@ -0,0 +1,53 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, favorite, archive)
SELECT title, uri, local_uri, description, epoch, length, duration, guid, played, podcast_id, 0, 0
FROM old_table;
Drop table old_table;
ALTER TABLE podcast RENAME TO old_table;
CREATE TABLE `podcast` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
`title` TEXT NOT NULL,
`link` TEXT NOT NULL,
`description` TEXT NOT NULL,
`image_uri` TEXT,
`source_id` INTEGER NOT NULL UNIQUE,
`favorite` INTEGER NOT NULL DEFAULT 0,
`archive` INTEGER NOT NULL DEFAULT 0,
`always_dl` INTEGER NOT NULL DEFAULT 0
);
INSERT INTO podcast (
id,
title,
link,
description,
image_uri,
source_id
) SELECT id,
title,
link,
description,
image_uri,
source_id
FROM old_table;
Drop table old_table;
@@ -0,0 +1,66 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
podcast_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
podcast_id
FROM old_table;
Drop table old_table;
ALTER TABLE podcast RENAME TO old_table;
CREATE TABLE `podcast` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
`title` TEXT NOT NULL,
`link` TEXT NOT NULL,
`description` TEXT NOT NULL,
`image_uri` TEXT,
`source_id` INTEGER NOT NULL UNIQUE
);
INSERT INTO podcast (
id,
title,
link,
description,
image_uri,
source_id
) SELECT id,
title,
link,
description,
image_uri,
source_id
FROM old_table;
Drop table old_table;
@@ -0,0 +1,40 @@
ALTER TABLE episodes RENAME TO old_table;
ALTER TABLE shows RENAME TO podcast;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
podcast_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
show_id
FROM old_table;
Drop table old_table;
@@ -0,0 +1,40 @@
ALTER TABLE episode RENAME TO old_table;
ALTER TABLE podcast RENAME TO shows;
CREATE TABLE episodes (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
show_id INTEGER NOT NULL,
PRIMARY KEY (title, show_id)
);
INSERT INTO episodes (
title,
uri,
local_uri,
description,
epoch,
length,
duration,
guid,
played,
show_id
) SELECT title,
uri,
local_uri,
description,
epoch, length,
duration,
guid,
played,
podcast_id
FROM old_table;
Drop table old_table;
+4 -2
View File
@@ -1,4 +1,6 @@
//! Database Setup. This is only public to help with some unit tests.
// Diesel embed_migrations! triggers the lint
#![allow(unused_imports)]
use diesel::prelude::*;
use diesel::r2d2;
@@ -66,8 +68,8 @@ fn run_migration_on(connection: &SqliteConnection) -> Result<(), DataError> {
pub fn truncate_db() -> Result<(), DataError> {
let db = connection();
let con = db.get()?;
con.execute("DELETE FROM episode")?;
con.execute("DELETE FROM podcast")?;
con.execute("DELETE FROM episodes")?;
con.execute("DELETE FROM shows")?;
con.execute("DELETE FROM source")?;
Ok(())
}
+141 -92
View File
@@ -5,6 +5,7 @@ use diesel::prelude::*;
use diesel;
use diesel::dsl::exists;
use diesel::query_builder::AsQuery;
use diesel::select;
use database::connection;
@@ -22,92 +23,106 @@ pub fn get_sources() -> Result<Vec<Source>, DataError> {
.map_err(From::from)
}
pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
use schema::podcast::dsl::*;
pub fn get_podcasts() -> Result<Vec<Show>, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
podcast
shows
.order(title.asc())
.load::<Podcast>(&con)
.load::<Show>(&con)
.map_err(From::from)
}
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError> {
use schema::podcast::dsl::*;
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Show>, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
podcast
shows
.order(title.asc())
.filter(id.ne_any(filter_ids))
.load::<Podcast>(&con)
.filter(id.ne_all(filter_ids))
.load::<Show>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episode::dsl::*;
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.select((rowid, local_uri, played))
.filter(local_uri.is_not_null())
.load::<EpisodeCleanerQuery>(&con)
.load::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> {
// use schema::episode::dsl::*;
// use schema::episodes::dsl::*;
// let db = connection();
// let con = db.get()?;
// episode
// episodes
// .filter(played.is_not_null())
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episode::dsl::*;
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.select((rowid, local_uri, played))
.filter(played.is_not_null())
.load::<EpisodeCleanerQuery>(&con)
.load::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.filter(rowid.eq(ep_id))
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*;
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetModel, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.select((
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
))
.filter(rowid.eq(ep_id))
.get_result::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.filter(rowid.eq(ep_id))
.select(local_uri)
.get_result::<Option<String>>(&con)
@@ -117,55 +132,48 @@ pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataE
pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32],
limit: u32,
) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode;
) -> Result<Vec<EpisodeWidgetModel>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
);
episode::table
.select((
episode::rowid,
episode::title,
episode::uri,
episode::local_uri,
episode::epoch,
episode::length,
episode::duration,
episode::played,
episode::podcast_id,
))
.order(episode::epoch.desc())
.filter(episode::podcast_id.ne_any(filter_ids))
episodes
.select(columns)
.order(epoch.desc())
.filter(show_id.ne_all(filter_ids))
.limit(i64::from(limit))
.load::<EpisodeWidgetQuery>(&con)
.load::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_id(pid: i32) -> Result<Podcast, DataError> {
use schema::podcast::dsl::*;
pub fn get_podcast_from_id(pid: i32) -> Result<Show, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
podcast
shows
.filter(id.eq(pid))
.get_result::<Podcast>(&con)
.get_result::<Show>(&con)
.map_err(From::from)
}
pub fn get_podcast_cover_from_id(pid: i32) -> Result<PodcastCoverQuery, DataError> {
use schema::podcast::dsl::*;
pub fn get_podcast_cover_from_id(pid: i32) -> Result<ShowCoverModel, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
podcast
shows
.select((id, title, image_uri))
.filter(id.eq(pid))
.get_result::<PodcastCoverQuery>(&con)
.get_result::<ShowCoverModel>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
pub fn get_pd_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
@@ -175,21 +183,34 @@ 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> {
use schema::episode::dsl::*;
pub fn get_pd_episodes_count(parent: &Show) -> Result<i64, DataError> {
let db = connection();
let con = db.get()?;
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
.filter(podcast_id.eq(parent.id()))
// .group_by(epoch)
.order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con)
Episode::belonging_to(parent)
.count()
.get_result(&con)
.map_err(From::from)
}
pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episode::dsl::*;
pub fn get_pd_episodeswidgets(parent: &Show) -> Result<Vec<EpisodeWidgetModel>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id,
);
episodes
.select(columns)
.filter(show_id.eq(parent.id()))
.order(epoch.desc())
.load::<EpisodeWidgetModel>(&con)
.map_err(From::from)
}
pub fn get_pd_unplayed_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
@@ -200,8 +221,8 @@ pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataEr
.map_err(From::from)
}
// pub(crate) fn get_pd_episodes_limit(parent: &Podcast, limit: u32) ->
// Result<Vec<Episode>, DataError> { use schema::episode::dsl::*;
// pub(crate) fn get_pd_episodes_limit(parent: &Show, limit: u32) ->
// Result<Vec<Episode>, DataError> { use schema::episodes::dsl::*;
// let db = connection();
// let con = db.get()?;
@@ -235,25 +256,25 @@ pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
.map_err(From::from)
}
pub fn get_podcast_from_source_id(sid: i32) -> Result<Podcast, DataError> {
use schema::podcast::dsl::*;
pub fn get_podcast_from_source_id(sid: i32) -> Result<Show, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
podcast
shows
.filter(source_id.eq(sid))
.get_result::<Podcast>(&con)
.get_result::<Show>(&con)
.map_err(From::from)
}
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
episodes
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.filter(show_id.eq(pid))
.get_result::<Episode>(&con)
.map_err(From::from)
}
@@ -262,19 +283,19 @@ pub(crate) fn get_episode_minimal_from_pk(
title_: &str,
pid: i32,
) -> Result<EpisodeMinimal, DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((rowid, title, uri, epoch, duration, guid, podcast_id))
episodes
.select((rowid, title, uri, epoch, length, duration, guid, show_id))
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.filter(show_id.eq(pid))
.get_result::<EpisodeMinimal>(&con)
.map_err(From::from)
}
pub(crate) fn remove_feed(pd: &Podcast) -> Result<(), DataError> {
pub(crate) fn remove_feed(pd: &Show) -> Result<(), DataError> {
let db = connection();
let con = db.get()?;
@@ -293,16 +314,16 @@ fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
diesel::delete(source.filter(id.eq(source_id))).execute(con)
}
fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
use schema::podcast::dsl::*;
fn delete_podcast(con: &SqliteConnection, show_id: i32) -> QueryResult<usize> {
use schema::shows::dsl::*;
diesel::delete(podcast.filter(id.eq(podcast_id))).execute(con)
diesel::delete(shows.filter(id.eq(show_id))).execute(con)
}
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(con)
diesel::delete(episodes.filter(show_id.eq(parent_id))).execute(con)
}
pub fn source_exists(url: &str) -> Result<bool, DataError> {
@@ -317,42 +338,70 @@ pub fn source_exists(url: &str) -> Result<bool, DataError> {
}
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> {
use schema::podcast::dsl::*;
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(podcast.filter(source_id.eq(source_id_))))
select(exists(shows.filter(source_id.eq(source_id_))))
.get_result(&con)
.map_err(From::from)
}
#[cfg_attr(rustfmt, rustfmt_skip)]
pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, DataError> {
use schema::episode::dsl::*;
pub(crate) fn episode_exists(title_: &str, show_id_: i32) -> Result<bool, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.filter(podcast_id.eq(podcast_id_)).filter(title.eq(title_))))
select(exists(episodes.filter(show_id.eq(show_id_)).filter(title.eq(title_))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `episodes table contains any rows
///
/// Return true if `episodes` table is populated.
pub fn is_episodes_populated() -> Result<bool, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episodes.as_query()))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `shows` table contains any rows
///
/// Return true if `shows table is populated.
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(shows.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(episode)
diesel::insert_into(episodes)
.values(eps)
.execute(&*con)
.map_err(From::from)
.map(|_| ())
}
pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
use schema::episode::dsl::*;
pub fn update_none_to_played_now(parent: &Show) -> Result<usize, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
@@ -369,7 +418,7 @@ pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
mod tests {
use super::*;
use database::*;
use pipeline::*;
use pipeline;
#[test]
fn test_update_none_to_played_now() {
@@ -379,7 +428,7 @@ mod tests {
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url).unwrap();
let id = source.id();
index_single_source(source, true).unwrap();
pipeline::run(vec![source], true).unwrap();
let pd = get_podcast_from_source_id(id).unwrap();
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
+33 -8
View File
@@ -5,9 +5,30 @@ use hyper;
use native_tls;
use rss;
use url;
use xml;
use std::io;
use models::Source;
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
#[derive(Fail, Debug)]
pub struct HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
}
impl HttpStatusError {
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
HttpStatusError {
url,
status_code: code,
context,
}
}
}
#[derive(Fail, Debug)]
pub enum DataError {
#[fail(display = "SQL Query failed: {}", _0)]
@@ -29,18 +50,16 @@ pub enum DataError {
IOError(#[cause] io::Error),
#[fail(display = "RSS Error: {}", _0)]
RssError(#[cause] rss::Error),
#[fail(display = "XML Reader Error: {}", _0)]
XmlReaderError(#[cause] xml::reader::Error),
#[fail(display = "Error: {}", _0)]
Bail(String),
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
},
#[fail(display = "{}", _0)]
HttpStatusGeneral(HttpStatusError),
#[fail(display = "FIXME: This should be better")]
F301(Source),
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
ParseEpisodeError { reason: String, parent_id: i32 },
#[fail(display = "No Futures where produced to be run.")]
EmptyFuturesList,
#[fail(display = "Episode was not changed and thus skipped.")]
EpisodeNotChanged,
}
@@ -99,6 +118,12 @@ impl From<rss::Error> for DataError {
}
}
impl From<xml::reader::Error> for DataError {
fn from(err: xml::reader::Error) -> Self {
DataError::XmlReaderError(err)
}
}
impl From<String> for DataError {
fn from(err: String) -> Self {
DataError::Bail(err)
+99 -108
View File
@@ -1,16 +1,15 @@
#![cfg_attr(feature = "cargo-clippy", allow(unit_arg))]
//! Index Feeds.
use futures::future::*;
use itertools::{Either, Itertools};
use futures::prelude::*;
use futures::stream;
use rss;
use dbqueries;
use errors::DataError;
use models::{Index, IndexState, Update};
use models::{NewEpisode, NewPodcast, Podcast};
use pipeline::*;
type InsertUpdate = (Vec<NewEpisode>, Vec<Option<(NewEpisode, i32)>>);
use models::{NewEpisode, NewEpisodeMinimal, NewShow, Show};
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field.
@@ -26,90 +25,104 @@ pub struct Feed {
impl Feed {
/// Index the contents of the RSS `Feed` into the database.
pub fn index(self) -> Box<Future<Item = (), Error = DataError> + Send> {
let fut = self.parse_podcast_async()
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send {
self.parse_podcast_async()
.and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(&pd));
Box::new(fut)
.and_then(move |pd| self.index_channel_items(pd))
}
fn parse_podcast(&self) -> NewPodcast {
NewPodcast::new(&self.channel, self.source_id)
fn parse_podcast(&self) -> NewShow {
NewShow::new(&self.channel, self.source_id)
}
fn parse_podcast_async(&self) -> Box<Future<Item = NewPodcast, Error = DataError> + Send> {
Box::new(ok(self.parse_podcast()))
fn parse_podcast_async(&self) -> impl Future<Item = NewShow, Error = DataError> + Send {
ok(self.parse_podcast())
}
fn index_channel_items(
&self,
pd: &Podcast,
) -> Box<Future<Item = (), Error = DataError> + Send> {
let fut = self.get_stuff(pd)
.and_then(|(insert, update)| {
if !insert.is_empty() {
info!("Indexing {} episodes.", insert.len());
if let Err(err) = dbqueries::index_new_episodes(insert.as_slice()) {
error!("Failed batch indexng, Fallign back to individual indexing.");
error!("{}", err);
insert.iter().for_each(|ep| {
if let Err(err) = ep.index() {
error!("Failed to index episode: {:?}.", ep.title());
error!("{}", err);
};
})
}
}
Ok((insert, update))
})
.map(|(_, update)| {
if !update.is_empty() {
info!("Updating {} episodes.", update.len());
// see get_stuff for more
update
.into_iter()
.filter_map(|x| x)
.for_each(|(ref ep, rowid)| {
if let Err(err) = ep.update(rowid) {
error!("Failed to index episode: {:?}.", ep.title());
error!("{}", err);
};
})
}
fn index_channel_items(self, pd: Show) -> impl Future<Item = (), Error = DataError> + Send {
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
// Parse the episodes
let episodes = stream.filter_map(move |item| {
glue(&item, pd.id())
.map_err(|err| error!("Failed to parse an episode: {}", err))
.ok()
});
// Filter errors, Index updatable episodes, return insertables.
filter_episodes(episodes)
// Batch index insertable episodes.
.and_then(|eps| ok(batch_insert_episodes(&eps)))
}
}
fn glue(item: &rss::Item, id: i32) -> Result<IndexState<NewEpisode>, DataError> {
NewEpisodeMinimal::new(item, id).and_then(move |ep| determine_ep_state(ep, item))
}
fn determine_ep_state(
ep: NewEpisodeMinimal,
item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.show_id())?;
if !exists {
Ok(IndexState::Index(ep.into_new_episode(item)))
} else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.show_id())?;
let rowid = old.rowid();
if ep != old {
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
} else {
Ok(IndexState::NotChanged)
}
}
}
fn filter_episodes<'a, S>(
stream: S,
) -> impl Future<Item = Vec<NewEpisode>, Error = DataError> + Send + 'a
where
S: Stream<Item = IndexState<NewEpisode>, Error = DataError> + Send + 'a,
{
stream.filter_map(|state| match state {
IndexState::NotChanged => None,
// Update individual rows, and filter them
IndexState::Update((ref ep, rowid)) => {
ep.update(rowid)
.map_err(|err| error!("{}", err))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.ok();
None
},
IndexState::Index(s) => Some(s),
})
// only Index is left, collect them for batch index
.collect()
}
fn batch_insert_episodes(episodes: &[NewEpisode]) {
if episodes.is_empty() {
return;
};
info!("Indexing {} episodes.", episodes.len());
dbqueries::index_new_episodes(episodes)
.map_err(|err| {
error!("Failed batch indexng: {}", err);
info!("Fallign back to individual indexing.");
})
.unwrap_or_else(|_| {
episodes.iter().for_each(|ep| {
ep.index()
.map_err(|err| error!("Error: {}.", err))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.ok();
});
Box::new(fut)
}
fn get_stuff(
&self,
pd: &Podcast,
) -> Box<Future<Item = InsertUpdate, Error = DataError> + Send> {
let (insert, update): (Vec<_>, Vec<_>) = self.channel
.items()
.into_iter()
.map(|item| glue_async(item, pd.id()))
// This is sort of ugly but I think it's cheaper than pushing None
// to updated and filtering it out later.
// Even though we already map_filter in index_channel_items.
// I am not sure what the optimizations are on match vs allocating None.
.map(|fut| {
fut.and_then(|x| match x {
IndexState::NotChanged => Err(DataError::EpisodeNotChanged),
_ => Ok(x),
})
})
.flat_map(|fut| fut.wait())
.partition_map(|state| match state {
IndexState::Index(e) => Either::Left(e),
IndexState::Update(e) => Either::Right(Some(e)),
// This should never occur
IndexState::NotChanged => Either::Right(None),
});
Box::new(ok((insert, update)))
}
})
}
#[cfg(test)]
@@ -117,10 +130,10 @@ mod tests {
use rss::Channel;
use tokio_core::reactor::Core;
use Source;
use database::truncate_db;
use dbqueries;
use utils::get_feed;
use Source;
use std::fs;
use std::io::BufReader;
@@ -161,7 +174,8 @@ mod tests {
fn test_complete_index() {
truncate_db().unwrap();
let feeds: Vec<_> = URLS.iter()
let feeds: Vec<_> = URLS
.iter()
.map(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
@@ -190,7 +204,7 @@ mod tests {
let file = fs::File::open(path).unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(feed.parse_podcast(), pd);
}
@@ -202,31 +216,8 @@ mod tests {
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
feed.index_channel_items(&pd).wait().unwrap();
feed.index_channel_items(pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
}
#[test]
fn test_feed_get_stuff() {
truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(43, insert.len());
assert_eq!(0, update.len());
feed.index().wait().unwrap();
let path = "tests/feeds/2018-02-03-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(4, insert.len());
assert_eq!(43, update.len());
}
}
+41 -22
View File
@@ -1,22 +1,29 @@
#![recursion_limit = "1024"]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
#![cfg_attr(feature = "clippy",
warn(option_unwrap_used, result_unwrap_used, print_stdout,
wrong_pub_self_convention, mut_mut, non_ascii_literal, similar_names,
unicode_not_nfc, enum_glob_use, if_not_else, items_after_statements,
used_underscore_binding))]
#![allow(unknown_lints)]
#![deny(bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals,
path_statements, patterns_in_fns_without_body, plugin_as_library, private_in_public,
private_no_mangle_fns, private_no_mangle_statics, safe_extern_statics,
unconditional_recursion, unions_with_drop_fields, unused_allocation, unused_comparisons,
unused_parens, while_true)]
#![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts)]
#![deny(unused_extern_crates, unused)]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
#![cfg_attr(feature = "cargo-clippy", allow(option_map_unit_fn))]
#![cfg_attr(
feature = "clippy",
warn(
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut,
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else,
items_after_statements, used_underscore_binding
)
)]
// Enable lint group collections
#![warn(nonstandard_style, edition_2018, rust_2018_idioms, bad_style, unused)]
// standalone lints
#![warn(
const_err, improper_ctypes, non_shorthand_field_patterns, no_mangle_generic_items,
overflowing_literals, plugin_as_library, private_no_mangle_fns, private_no_mangle_statics,
unconditional_recursion, unions_with_drop_fields, while_true, missing_debug_implementations,
missing_docs, trivial_casts, trivial_numeric_casts, elided_lifetime_in_paths,
missing_copy_implementations
)]
#![deny(warnings)]
// #![feature(conservative_impl_trait)]
// warn when code is not using dyn Trait syntax. req rustc 1.27
// #![deny(bare_trait_object)]
//! FIXME: Docs
@@ -24,6 +31,10 @@
#[macro_use]
extern crate pretty_assertions;
#[cfg(test)]
#[macro_use]
extern crate maplit;
#[macro_use]
extern crate derive_builder;
#[macro_use]
@@ -44,31 +55,39 @@ extern crate chrono;
extern crate futures;
extern crate hyper;
extern crate hyper_tls;
extern crate itertools;
extern crate native_tls;
extern crate num_cpus;
extern crate rayon;
extern crate rayon_futures;
extern crate rfc822_sanitizer;
extern crate rss;
extern crate tokio_core;
extern crate url;
extern crate xdg;
extern crate xml;
pub mod database;
#[allow(missing_docs)]
pub mod dbqueries;
#[allow(missing_docs)]
pub mod errors;
pub mod utils;
pub mod database;
pub mod pipeline;
pub(crate) mod models;
mod feed;
pub(crate) mod models;
pub mod opml;
mod parser;
pub mod pipeline;
mod schema;
pub mod utils;
pub use feed::{Feed, FeedBuilder};
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
pub use models::Save;
pub use models::{Episode, EpisodeWidgetModel, Show, ShowCoverModel, Source};
// Set the user agent, See #53 for more
// Keep this in sync with Tor-browser releases
/// The user-agent to be used for all the requests.
/// It originates from the Tor-browser UA.
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0";
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]
+66 -96
View File
@@ -1,18 +1,18 @@
use chrono::prelude::*;
use diesel;
use diesel::SaveChangesDsl;
use diesel::prelude::*;
use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
use models::{Podcast, Save};
use schema::episode;
use models::{Save, Show};
use schema::episodes;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[belongs_to(Podcast, foreign_key = "podcast_id")]
#[primary_key(title, show_id)]
#[belongs_to(Show, foreign_key = "show_id")]
#[derive(Debug, Clone)]
/// Diesel Model of the episode table.
pub struct Episode {
@@ -26,15 +26,15 @@ pub struct Episode {
duration: Option<i32>,
guid: Option<String>,
played: Option<i32>,
favorite: bool,
archive: bool,
podcast_id: i32,
show_id: i32,
}
impl Save<Episode, DataError> for Episode {
impl Save<Episode> for Episode {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Episode, DataError> {
fn save(&self) -> Result<Episode, Self::Error> {
let db = connection();
let tempdb = db.get()?;
@@ -152,32 +152,9 @@ impl Episode {
self.played = value;
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
///
/// If true, the download cleanr will ignore the episode
/// and the corresponding media value will never be automaticly deleted.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Episode`.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
@@ -189,12 +166,12 @@ impl Episode {
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for constructing `EpisodeWidgets`.
pub struct EpisodeWidgetQuery {
pub struct EpisodeWidgetModel {
rowid: i32,
title: String,
uri: Option<String>,
@@ -203,14 +180,12 @@ pub struct EpisodeWidgetQuery {
length: Option<i32>,
duration: Option<i32>,
played: Option<i32>,
// favorite: bool,
// archive: bool,
podcast_id: i32,
show_id: i32,
}
impl From<Episode> for EpisodeWidgetQuery {
fn from(e: Episode) -> EpisodeWidgetQuery {
EpisodeWidgetQuery {
impl From<Episode> for EpisodeWidgetModel {
fn from(e: Episode) -> EpisodeWidgetModel {
EpisodeWidgetModel {
rowid: e.rowid,
title: e.title,
uri: e.uri,
@@ -219,28 +194,30 @@ impl From<Episode> for EpisodeWidgetQuery {
length: e.length,
duration: e.duration,
played: e.played,
podcast_id: e.podcast_id,
show_id: e.show_id,
}
}
}
impl Save<usize, DataError> for EpisodeWidgetQuery {
impl Save<usize> for EpisodeWidgetModel {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
fn save(&self) -> Result<usize, Self::Error> {
use schema::episodes::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
diesel::update(episodes.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl EpisodeWidgetQuery {
impl EpisodeWidgetModel {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
@@ -315,32 +292,9 @@ impl EpisodeWidgetQuery {
self.played = value;
}
// /// Represents the archiving policy for the episode.
// pub fn archive(&self) -> bool {
// self.archive
// }
// /// Set the `archive` policy.
// ///
// /// If true, the download cleanr will ignore the episode
// /// and the corresponding media value will never be automaticly deleted.
// pub fn set_archive(&mut self, b: bool) {
// self.archive = b
// }
// /// Get the `favorite` status of the `Episode`.
// pub fn favorite(&self) -> bool {
// self.favorite
// }
// /// Set `favorite` status.
// pub fn set_favorite(&mut self, b: bool) {
// self.favorite = b
// }
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
@@ -352,36 +306,38 @@ impl EpisodeWidgetQuery {
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used internal with the `utils::checkup` function.
pub struct EpisodeCleanerQuery {
pub struct EpisodeCleanerModel {
rowid: i32,
local_uri: Option<String>,
played: Option<i32>,
}
impl Save<usize, DataError> for EpisodeCleanerQuery {
impl Save<usize> for EpisodeCleanerModel {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<usize, DataError> {
use schema::episode::dsl::*;
fn save(&self) -> Result<usize, Self::Error> {
use schema::episodes::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
diesel::update(episodes.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl From<Episode> for EpisodeCleanerQuery {
fn from(e: Episode) -> EpisodeCleanerQuery {
EpisodeCleanerQuery {
impl From<Episode> for EpisodeCleanerModel {
fn from(e: Episode) -> EpisodeCleanerModel {
EpisodeCleanerModel {
rowid: e.rowid(),
local_uri: e.local_uri,
played: e.played,
@@ -389,7 +345,7 @@ impl From<Episode> for EpisodeCleanerQuery {
}
}
impl EpisodeCleanerQuery {
impl EpisodeCleanerModel {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
@@ -422,9 +378,9 @@ impl EpisodeCleanerQuery {
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[primary_key(title, show_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for FIXME.
pub struct EpisodeMinimal {
@@ -432,9 +388,10 @@ pub struct EpisodeMinimal {
title: String,
uri: Option<String>,
epoch: i32,
length: Option<i32>,
duration: Option<i32>,
guid: Option<String>,
podcast_id: i32,
show_id: i32,
}
impl From<Episode> for EpisodeMinimal {
@@ -443,10 +400,11 @@ impl From<Episode> for EpisodeMinimal {
rowid: e.rowid,
title: e.title,
uri: e.uri,
length: e.length,
guid: e.guid,
epoch: e.epoch,
duration: e.duration,
podcast_id: e.podcast_id,
show_id: e.show_id,
}
}
}
@@ -482,6 +440,18 @@ impl EpisodeMinimal {
self.epoch
}
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
@@ -489,8 +459,8 @@ impl EpisodeMinimal {
self.duration
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
/// `Show` table foreign key.
pub fn show_id(&self) -> i32 {
self.show_id
}
}
+23 -15
View File
@@ -1,26 +1,26 @@
mod new_episode;
mod new_podcast;
mod new_show;
mod new_source;
mod episode;
mod podcast;
mod show;
mod source;
// use futures::prelude::*;
// use futures::future::*;
pub(crate) use self::episode::EpisodeCleanerQuery;
pub(crate) use self::episode::EpisodeCleanerModel;
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
pub(crate) use self::new_podcast::NewPodcast;
pub(crate) use self::new_show::NewShow;
pub(crate) use self::new_source::NewSource;
#[cfg(test)]
pub(crate) use self::new_episode::NewEpisodeBuilder;
#[cfg(test)]
pub(crate) use self::new_podcast::NewPodcastBuilder;
pub(crate) use self::new_show::NewShowBuilder;
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetQuery};
pub use self::podcast::{Podcast, PodcastCoverQuery};
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetModel};
pub use self::show::{Show, ShowCoverModel};
pub use self::source::Source;
#[derive(Debug, Clone, PartialEq)]
@@ -30,22 +30,30 @@ pub enum IndexState<T> {
NotChanged,
}
pub trait Insert<T, E> {
fn insert(&self) -> Result<T, E>;
pub trait Insert<T> {
type Error;
fn insert(&self) -> Result<T, Self::Error>;
}
pub trait Update<T, E> {
fn update(&self, i32) -> Result<T, E>;
pub trait Update<T> {
type Error;
fn update(&self, i32) -> Result<T, Self::Error>;
}
// This might need to change in the future
pub trait Index<T, E>: Insert<T, E> + Update<T, E> {
fn index(&self) -> Result<T, E>;
pub trait Index<T>: Insert<T> + Update<T> {
type Error;
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
}
/// FIXME: DOCS
pub trait Save<T, E> {
pub trait Save<T> {
/// The Error type to be returned.
type Error;
/// Helper method to easily save/"sync" current state of a diesel model to
/// the Database.
fn save(&self) -> Result<T, E>;
fn save(&self) -> Result<T, Self::Error>;
}
+101 -78
View File
@@ -9,11 +9,11 @@ use dbqueries;
use errors::DataError;
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
use parser;
use schema::episode;
use schema::episodes;
use utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
@@ -26,7 +26,7 @@ pub(crate) struct NewEpisode {
duration: Option<i32>,
guid: Option<String>,
epoch: i32,
podcast_id: i32,
show_id: i32,
}
impl From<NewEpisodeMinimal> for NewEpisode {
@@ -36,21 +36,23 @@ impl From<NewEpisodeMinimal> for NewEpisode {
.uri(e.uri)
.duration(e.duration)
.epoch(e.epoch)
.podcast_id(e.podcast_id)
.show_id(e.show_id)
.guid(e.guid)
.build()
.unwrap()
}
}
impl Insert<(), DataError> for NewEpisode {
impl Insert<()> for NewEpisode {
type Error = DataError;
fn insert(&self) -> Result<(), DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
info!("Inserting {:?}", self.title);
diesel::insert_into(episode)
diesel::insert_into(episodes)
.values(self)
.execute(&con)
.map_err(From::from)
@@ -58,14 +60,16 @@ impl Insert<(), DataError> for NewEpisode {
}
}
impl Update<(), DataError> for NewEpisode {
impl Update<()> for NewEpisode {
type Error = DataError;
fn update(&self, episode_id: i32) -> Result<(), DataError> {
use schema::episode::dsl::*;
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {:?}", self.title);
diesel::update(episode.filter(rowid.eq(episode_id)))
diesel::update(episodes.filter(rowid.eq(episode_id)))
.set(self)
.execute(&con)
.map_err(From::from)
@@ -73,14 +77,16 @@ impl Update<(), DataError> for NewEpisode {
}
}
impl Index<(), DataError> for NewEpisode {
impl Index<()> for NewEpisode {
type Error = DataError;
// Does not update the episode description if it's the only thing that has
// changed.
fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
let exists = dbqueries::episode_exists(self.title(), self.show_id())?;
if exists {
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.podcast_id())?;
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.show_id())?;
if self != &other {
self.update(other.rowid())
@@ -95,17 +101,23 @@ impl Index<(), DataError> for NewEpisode {
impl PartialEq<EpisodeMinimal> for NewEpisode {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
}
}
impl PartialEq<Episode> for NewEpisode {
fn eq(&self, other: &Episode) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
&& (self.description() == other.description())
&& (self.length() == other.length())
}
@@ -114,14 +126,14 @@ impl PartialEq<Episode> for NewEpisode {
impl NewEpisode {
/// Parses an `rss::Item` into a `NewEpisode` Struct.
#[allow(dead_code)]
pub(crate) fn new(item: &rss::Item, podcast_id: i32) -> Result<Self, DataError> {
NewEpisodeMinimal::new(item, podcast_id).map(|ep| ep.into_new_episode(item))
pub(crate) fn new(item: &rss::Item, show_id: i32) -> Result<Self, DataError> {
NewEpisodeMinimal::new(item, show_id).map(|ep| ep.into_new_episode(item))
}
#[allow(dead_code)]
pub(crate) fn to_episode(&self) -> Result<Episode, DataError> {
self.index()?;
dbqueries::get_episode_from_pk(&self.title, self.podcast_id).map_err(From::from)
dbqueries::get_episode_from_pk(&self.title, self.show_id).map_err(From::from)
}
}
@@ -155,30 +167,34 @@ impl NewEpisode {
self.length
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
pub(crate) fn show_id(&self) -> i32 {
self.show_id
}
}
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[table_name = "episodes"]
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewEpisodeMinimal {
title: String,
uri: Option<String>,
length: Option<i32>,
duration: Option<i32>,
epoch: i32,
guid: Option<String>,
podcast_id: i32,
show_id: i32,
}
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
(self.title() == other.title())
&& (self.uri() == other.uri())
&& (self.duration() == other.duration())
&& (self.epoch() == other.epoch())
&& (self.guid() == other.guid())
&& (self.show_id() == other.show_id())
}
}
@@ -196,12 +212,19 @@ impl NewEpisodeMinimal {
let title = item.title().unwrap().trim().to_owned();
let guid = item.guid().map(|s| s.value().trim().to_owned());
let uri = item.enclosure()
.map(|s| url_cleaner(s.url()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s)));
// Get the mime type, the `http` url and the length from the enclosure
// http://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt
let enc = item.enclosure();
// If url is still None return an Error as this behaviour is
// Get the url
let uri = enc.map(|s| url_cleaner(s.url().trim()))
// Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s.trim())));
// Get the size of the content, it should be in bytes
let length = enc.and_then(|x| x.length().parse().ok());
// If url is still None return an Error as this behaviour is not
// compliant with the RSS Spec.
if uri.is_none() {
let err = DataError::ParseEpisodeError {
@@ -223,27 +246,35 @@ impl NewEpisodeMinimal {
NewEpisodeMinimalBuilder::default()
.title(title)
.uri(uri)
.length(length)
.duration(duration)
.epoch(epoch)
.guid(guid)
.podcast_id(parent_id)
.show_id(parent_id)
.build()
.map_err(From::from)
}
// TODO: TryInto is stabilizing in rustc v1.26!
// ^ Jokes on you past self!
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
let length = item.enclosure().and_then(|x| x.length().parse().ok());
let description = item.description().map(|s| ammonia::clean(s));
let description = item.description().and_then(|s| {
let sanitized_html = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(s.trim())
.to_string();
Some(sanitized_html)
});
NewEpisodeBuilder::default()
.title(self.title)
.uri(self.uri)
.duration(self.duration)
.epoch(self.epoch)
.podcast_id(self.podcast_id)
.show_id(self.show_id)
.guid(self.guid)
.length(length)
.length(self.length)
.description(description)
.build()
.unwrap()
@@ -272,16 +303,17 @@ impl NewEpisodeMinimal {
self.epoch
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
pub(crate) fn show_id(&self) -> i32 {
self.show_id
}
}
#[cfg(test)]
mod tests {
use database::truncate_db;
use dbqueries;
use models::*;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use models::*;
use rss::Channel;
@@ -301,8 +333,9 @@ mod tests {
)))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.epoch(1505296800)
.length(Some(66738886))
.duration(Some(4171))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -314,8 +347,9 @@ mod tests {
)))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.epoch(1502272800)
.length(Some(67527575))
.duration(Some(4415))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -336,7 +370,7 @@ mod tests {
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(4171))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -360,7 +394,7 @@ mod tests {
.length(Some(67527575))
.epoch(1502272800)
.duration(Some(4415))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -375,7 +409,7 @@ mod tests {
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(424242))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -386,9 +420,10 @@ mod tests {
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
)))
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.length(Some(46479789))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -400,8 +435,9 @@ mod tests {
)))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.epoch(1504670247)
.length(Some(36544272))
.duration(Some(4491))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -421,7 +457,7 @@ mod tests {
.length(Some(46479789))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -443,7 +479,7 @@ mod tests {
.length(Some(36544272))
.epoch(1504670247)
.duration(Some(4491))
.podcast_id(42)
.show_id(42)
.build()
.unwrap()
};
@@ -517,6 +553,10 @@ mod tests {
let ep = EXPECTED_MINIMAL_INTERCEPTED_1
.clone()
.into_new_episode(&item);
println!(
"EPISODE: {:#?}\nEXPECTED: {:#?}",
ep, *EXPECTED_INTERCEPTED_1
);
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let item = channel.items().iter().nth(15).unwrap();
@@ -536,7 +576,7 @@ mod tests {
let episode = channel.items().iter().nth(14).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id()).unwrap();
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1);
@@ -545,7 +585,7 @@ mod tests {
let episode = channel.items().iter().nth(15).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id()).unwrap();
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2);
@@ -559,21 +599,15 @@ mod tests {
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
updated.update(old.rowid()).unwrap();
let mut new = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
let new = dbqueries::get_episode_from_pk(old.title(), old.show_id()).unwrap();
// Assert that updating does not change the rowid and podcast_id
// Assert that updating does not change the rowid and show_id
assert_ne!(old, new);
assert_eq!(old.rowid(), new.rowid());
assert_eq!(old.podcast_id(), new.podcast_id());
assert_eq!(old.show_id(), new.show_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
assert_eq!(true, new2.archive());
}
#[test]
@@ -586,7 +620,7 @@ mod tests {
// Second identical, This should take the early return path
assert!(expected.index().is_ok());
// Get the episode
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id()).unwrap();
// Assert that NewPodcast is equal to the Indexed one
assert_eq!(*expected, old);
@@ -595,23 +629,22 @@ mod tests {
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
let new = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let new = dbqueries::get_episode_from_pk(expected.title(), expected.show_id()).unwrap();
// Assert it's diff from the old one.
assert_ne!(new, old);
assert_eq!(*updated, new);
assert_eq!(new.rowid(), old.rowid());
assert_eq!(new.podcast_id(), old.podcast_id());
assert_eq!(new.show_id(), old.show_id());
}
#[test]
fn test_new_episode_to_episode() {
let expected = &*EXPECTED_INTERCEPTED_1;
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
// Assert insert() produces the same result that you would get with to_podcast()
truncate_db().unwrap();
expected.insert().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id()).unwrap();
let ep = expected.to_episode().unwrap();
assert_eq!(old, ep);
@@ -620,17 +653,7 @@ mod tests {
let ep = expected.to_episode().unwrap();
// This should error as a unique constrain violation
assert!(expected.insert().is_err());
let mut old =
dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id()).unwrap();
assert_eq!(old, ep);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let ep = updated.to_episode().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
assert_eq!(old, ep);
assert_eq!(old.archive(), true);
}
}
@@ -4,21 +4,21 @@ use diesel::prelude::*;
use rss;
use errors::DataError;
use models::Show;
use models::{Index, Insert, Update};
use models::Podcast;
use schema::podcast;
use schema::shows;
use database::connection;
use dbqueries;
use utils::url_cleaner;
#[derive(Insertable, AsChangeset)]
#[table_name = "podcast"]
#[table_name = "shows"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewPodcast {
pub(crate) struct NewShow {
title: String,
link: String,
description: String,
@@ -26,13 +26,15 @@ pub(crate) struct NewPodcast {
source_id: i32,
}
impl Insert<(), DataError> for NewPodcast {
fn insert(&self) -> Result<(), DataError> {
use schema::podcast::dsl::*;
impl Insert<()> for NewShow {
type Error = DataError;
fn insert(&self) -> Result<(), Self::Error> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(podcast)
diesel::insert_into(shows)
.values(self)
.execute(&con)
.map(|_| ())
@@ -40,14 +42,16 @@ impl Insert<(), DataError> for NewPodcast {
}
}
impl Update<(), DataError> for NewPodcast {
fn update(&self, podcast_id: i32) -> Result<(), DataError> {
use schema::podcast::dsl::*;
impl Update<()> for NewShow {
type Error = DataError;
fn update(&self, show_id: i32) -> Result<(), Self::Error> {
use schema::shows::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {}", self.title);
diesel::update(podcast.filter(id.eq(podcast_id)))
diesel::update(shows.filter(id.eq(show_id)))
.set(self)
.execute(&con)
.map(|_| ())
@@ -57,7 +61,9 @@ impl Update<(), DataError> for NewPodcast {
// TODO: Maybe return an Enum<Action(Resut)> Instead.
// It would make unti testing better too.
impl Index<(), DataError> for NewPodcast {
impl Index<()> for NewShow {
type Error = DataError;
fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::podcast_exists(self.source_id)?;
@@ -75,31 +81,37 @@ impl Index<(), DataError> for NewPodcast {
}
}
impl PartialEq<Podcast> for NewPodcast {
fn eq(&self, other: &Podcast) -> bool {
(self.link() == other.link()) && (self.title() == other.title())
impl PartialEq<Show> for NewShow {
fn eq(&self, other: &Show) -> bool {
(self.link() == other.link())
&& (self.title() == other.title())
&& (self.image_uri() == other.image_uri())
&& (self.description() == other.description())
&& (self.source_id() == other.source_id())
}
}
impl NewPodcast {
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
impl NewShow {
/// Parses a `rss::Channel` into a `NewShow` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewShow {
let title = chan.title().trim();
let link = url_cleaner(chan.link().trim());
let description = ammonia::clean(chan.description().trim());
let link = url_cleaner(chan.link());
let description = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(chan.description().trim())
.to_string();
// Try to get the itunes img first
let itunes_img = chan.itunes_ext()
.and_then(|s| s.image())
let itunes_img = chan
.itunes_ext()
.and_then(|s| s.image().map(|url| url.trim()))
.map(|s| s.to_owned());
// If itunes is None, try to get the channel.image from the rss spec
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().to_owned()));
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned()));
NewPodcastBuilder::default()
NewShowBuilder::default()
.title(title)
.description(description)
.link(link)
@@ -110,14 +122,14 @@ impl NewPodcast {
}
// Look out for when tryinto lands into stable.
pub(crate) fn to_podcast(&self) -> Result<Podcast, DataError> {
pub(crate) fn to_podcast(&self) -> Result<Show, DataError> {
self.index()?;
dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from)
}
}
// Ignore the following geters. They are used in unit tests mainly.
impl NewPodcast {
impl NewShow {
#[allow(dead_code)]
pub(crate) fn source_id(&self) -> i32 {
self.source_id
@@ -148,14 +160,14 @@ mod tests {
use rss::Channel;
use database::truncate_db;
use models::{NewPodcastBuilder, Save};
use models::NewShowBuilder;
use std::fs::File;
use std::io::BufReader;
// Pre-built expected NewPodcast structs.
// Pre-built expected NewShow structs.
lazy_static! {
static ref EXPECTED_INTERCEPTED: NewPodcast = {
static ref EXPECTED_INTERCEPTED: NewShow = {
let descr = "The people behind The Intercepts fearless reporting and incisive \
commentaryJeremy Scahill, Glenn Greenwald, Betsy Reed and \
othersdiscuss the crucial issues of our time: national security, civil \
@@ -163,7 +175,7 @@ mod tests {
artists, thinkers, and newsmakers who challenge our preconceptions about \
the world we live in.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description(descr)
@@ -176,12 +188,12 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_LUP: NewPodcast = {
static ref EXPECTED_LUP: NewShow = {
let descr = "An open show powered by community LINUX Unplugged takes the best \
attributes of open collaboration and focuses them into a weekly \
lifestyle show about Linux.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("LINUX Unplugged Podcast")
.link("http://www.jupiterbroadcasting.com/")
.description(descr)
@@ -192,7 +204,7 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_TIPOFF: NewPodcast = {
static ref EXPECTED_TIPOFF: NewShow = {
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
scenes of some of the best investigative journalism from recent years. \
Each episode well be digging into an investigative scoop- hearing from \
@@ -203,7 +215,7 @@ mod tests {
complicated detective work that goes into doing great investigative \
journalism- then this is the podcast for you.</p>";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("The Tip Off")
.link("http://www.acast.com/thetipoff")
.description(desc)
@@ -215,7 +227,7 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_STARS: NewPodcast = {
static ref EXPECTED_STARS: NewShow = {
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars \
is a gripping noir science fiction thriller in 14 episodes: Forbidden \
love, a crashed UFO, an alien body, and an impossible heist unlike any \
@@ -225,7 +237,7 @@ mod tests {
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Steal the Stars")
.link("http://tor-labs.com/")
.description(descr)
@@ -234,12 +246,12 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_CODE: NewPodcast = {
static ref EXPECTED_CODE: NewShow = {
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \
David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \
Livingston-Gray. Brought to you by @therubyrep.";
NewPodcastBuilder::default()
NewShowBuilder::default()
.title("Greater Than Code")
.link("https://www.greaterthancode.com/")
.description(descr)
@@ -250,8 +262,8 @@ mod tests {
.build()
.unwrap()
};
static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
NewPodcastBuilder::default()
static ref EXPECTED_ELLINOFRENEIA: NewShow = {
NewShowBuilder::default()
.title("Ελληνοφρένεια")
.link("https://ellinofreneia.sealabs.net/feed.rss")
.description("Ανεπίσημο feed της Ελληνοφρένειας")
@@ -260,8 +272,8 @@ mod tests {
.build()
.unwrap()
};
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
NewPodcastBuilder::default()
static ref UPDATED_DESC_INTERCEPTED: NewShow = {
NewShowBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description("New Description")
@@ -281,7 +293,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_INTERCEPTED, pd);
}
@@ -290,7 +302,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_LUP, pd);
}
@@ -299,7 +311,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_TIPOFF, pd);
}
@@ -308,7 +320,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_STARS, pd);
}
@@ -317,7 +329,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_CODE, pd);
}
@@ -326,7 +338,7 @@ mod tests {
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);
let pd = NewShow::new(&channel, 42);
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
}
@@ -337,7 +349,7 @@ mod tests {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let npd = NewPodcast::new(&channel, 42);
let npd = NewShow::new(&channel, 42);
npd.insert().unwrap();
let pd = dbqueries::get_podcast_from_source_id(42).unwrap();
@@ -357,20 +369,13 @@ mod tests {
let updated = &*UPDATED_DESC_INTERCEPTED;
updated.update(old.id()).unwrap();
let mut new = dbqueries::get_podcast_from_source_id(42).unwrap();
let new = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_ne!(old, new);
assert_eq!(old.id(), new.id());
assert_eq!(old.source_id(), new.source_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
// Chech that the update does not override user preferences.
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(true, new2.archive());
}
#[test]
@@ -383,14 +388,14 @@ mod tests {
assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Get the podcast
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert that NewPodcast is equal to the Indexed one
// Assert that NewShow is equal to the Indexed one
assert_eq!(&*EXPECTED_INTERCEPTED, &old);
let updated = &*UPDATED_DESC_INTERCEPTED;
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
// Get the new Show
let new = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert it's diff from the old one.
assert_ne!(new, old);
@@ -412,16 +417,7 @@ mod tests {
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
// This should error as a unique constrain violation
assert!(EXPECTED_INTERCEPTED.insert().is_err());
let mut old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let pd = UPDATED_DESC_INTERCEPTED.to_podcast().unwrap();
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd);
assert_eq!(old.archive(), true);
}
}
-2
View File
@@ -1,5 +1,3 @@
#![allow(unused_mut)]
use diesel;
use diesel::prelude::*;
use url::Url;
@@ -3,40 +3,37 @@ use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
use models::{Save, Source};
use schema::podcast;
use std::sync::Arc;
use schema::shows;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "podcast"]
#[table_name = "shows"]
#[derive(Debug, Clone)]
/// Diesel Model of the podcast table.
pub struct Podcast {
/// Diesel Model of the shows table.
pub struct Show {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
favorite: bool,
archive: bool,
always_dl: bool,
source_id: i32,
}
impl Save<Podcast, DataError> for Podcast {
impl Save<Show> for Show {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Podcast, DataError> {
fn save(&self) -> Result<Show, Self::Error> {
let db = connection();
let tempdb = db.get()?;
self.save_changes::<Podcast>(&*tempdb).map_err(From::from)
self.save_changes::<Show>(&*tempdb).map_err(From::from)
}
}
impl Podcast {
impl Show {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
@@ -54,7 +51,7 @@ impl Podcast {
&self.link
}
/// Set the Podcast/Feed `link`.
/// Set the Show/Feed `link`.
pub fn set_link(&mut self, value: &str) {
self.link = value.to_string();
}
@@ -81,41 +78,6 @@ impl Podcast {
self.image_uri = value.map(|x| x.to_string());
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Podcast` Feed.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// Represents the download policy for the `Podcast` Feed.
///
/// Reserved for the use with a Download manager, yet to be implemented.
///
/// If true Podcast Episode should be downloaded automaticly/skipping
/// the selection queue.
pub fn always_download(&self) -> bool {
self.always_dl
}
/// Set the download policy.
pub fn set_always_download(&mut self, b: bool) {
self.always_dl = b
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
@@ -123,17 +85,17 @@ impl Podcast {
}
#[derive(Queryable, Debug, Clone)]
/// Diesel Model of the podcast cover query.
/// Used for fetching information about a Podcast's cover.
pub struct PodcastCoverQuery {
/// Diesel Model of the Show cover query.
/// Used for fetching information about a Show's cover.
pub struct ShowCoverModel {
id: i32,
title: String,
image_uri: Option<String>,
}
impl From<Podcast> for PodcastCoverQuery {
fn from(p: Podcast) -> PodcastCoverQuery {
PodcastCoverQuery {
impl From<Show> for ShowCoverModel {
fn from(p: Show) -> ShowCoverModel {
ShowCoverModel {
id: p.id(),
title: p.title,
image_uri: p.image_uri,
@@ -141,17 +103,7 @@ impl From<Podcast> for PodcastCoverQuery {
}
}
impl From<Arc<Podcast>> for PodcastCoverQuery {
fn from(p: Arc<Podcast>) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title.clone(),
image_uri: p.image_uri.clone(),
}
}
}
impl PodcastCoverQuery {
impl ShowCoverModel {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
+50 -46
View File
@@ -3,20 +3,23 @@ use diesel::SaveChangesDsl;
use rss::Channel;
use url::Url;
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper::client::HttpConnector;
use hyper::header::{ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified,
Location, UserAgent};
use hyper::header::{
ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified, Location, UserAgent,
};
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper_tls::HttpsConnector;
// use futures::future::ok;
use futures::future::{loop_fn, Future, Loop};
use futures::prelude::*;
use database::connection;
use errors::DataError;
use errors::*;
use feed::{Feed, FeedBuilder};
use models::{NewSource, Save};
use schema::source;
use USER_AGENT;
use std::str::FromStr;
@@ -32,10 +35,12 @@ pub struct Source {
http_etag: Option<String>,
}
impl Save<Source, DataError> for Source {
impl Save<Source> for Source {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Source, DataError> {
fn save(&self) -> Result<Source, Self::Error> {
let db = connection();
let con = db.get()?;
@@ -102,11 +107,7 @@ impl Source {
}
fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: context.into(),
}
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into()))
}
// TODO match on more stuff
@@ -119,7 +120,7 @@ impl Source {
// 408: Timeout
// 410: Feed deleted
// TODO: Rething this api,
fn match_status(mut self, res: Response) -> Result<(Self, Response), DataError> {
fn match_status(mut self, res: Response) -> Result<Response, DataError> {
self.update_etag(&res)?;
let code = res.status();
@@ -128,7 +129,7 @@ impl Source {
StatusCode::MovedPermanently => {
error!("Feed was moved permanently.");
self.handle_301(&res)?;
return Err(self.make_err("301: Feed was moved permanently.", code));
return Err(DataError::F301(self));
}
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
@@ -139,7 +140,7 @@ impl Source {
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
_ => info!("HTTP StatusCode: {}", code),
};
Ok((self, res))
Ok(res)
}
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
@@ -151,8 +152,6 @@ impl Source {
self.last_modified = None;
self.save()?;
info!("Feed url was updated succesfully.");
// TODO: Refresh in place instead of next time, Not a priority.
info!("New content will be fetched with the next refesh.");
}
Ok(())
@@ -177,21 +176,34 @@ impl Source {
// Refactor into TryInto once it lands on stable.
pub fn into_feed(
self,
client: &Client<HttpsConnector<HttpConnector>>,
client: Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = Feed, Error = DataError>> {
) -> impl Future<Item = Feed, Error = DataError> {
let id = self.id();
let feed = self.request_constructor(client, ignore_etags)
.and_then(move |(_, res)| response_to_channel(res))
let response = loop_fn(self, move |source| {
source
.request_constructor(&client.clone(), ignore_etags)
.then(|res| match res {
Ok(response) => Ok(Loop::Break(response)),
Err(err) => match err {
DataError::F301(s) => {
info!("Following redirect...");
Ok(Loop::Continue(s))
}
e => Err(e),
},
})
});
response
.and_then(response_to_channel)
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.map_err(From::from)
});
Box::new(feed)
})
}
// TODO: make ignore_etags an Enum for better ergonomics.
@@ -200,51 +212,43 @@ impl Source {
self,
client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = (Self, Response), Error = DataError>> {
) -> impl Future<Item = Response, Error = DataError> {
// FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::new(Method::Get, uri);
// Set the 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",
));
// Set the UserAgent cause ppl still seem to check it for some reason...
req.headers_mut().set(UserAgent::new(USER_AGENT));
if !ignore_etags {
if let Some(foo) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(vec![
EntityTag::new(true, foo.to_owned()),
]));
if let Some(etag) = self.http_etag() {
let tag = vec![EntityTag::new(true, etag.to_owned())];
req.headers_mut().set(IfNoneMatch::Items(tag));
}
if let Some(foo) = self.last_modified() {
if let Ok(x) = foo.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(x));
if let Some(lmod) = self.last_modified() {
if let Ok(date) = lmod.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(date));
}
}
}
let work = client
client
.request(req)
.map_err(From::from)
// TODO: tail recursion loop that would follow redirects directly
.and_then(move |res| self.match_status(res));
Box::new(work)
.and_then(move |res| self.match_status(res))
}
}
#[allow(needless_pass_by_value)]
fn response_to_channel(res: Response) -> Box<Future<Item = Channel, Error = DataError> + Send> {
let chan = res.body()
fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = DataError> + Send {
res.body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| Channel::from_str(&buf).map_err(From::from));
Box::new(chan)
.and_then(|buf| Channel::from_str(&buf).map_err(From::from))
}
#[cfg(test)]
@@ -269,7 +273,7 @@ mod tests {
let source = Source::from_url(url).unwrap();
let id = source.id();
let feed = source.into_feed(&client, true);
let feed = source.into_feed(client, true);
let feed = core.run(feed).unwrap();
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
+167
View File
@@ -0,0 +1,167 @@
//! FIXME: Docs
// #![allow(unused)]
use errors::DataError;
use models::Source;
use xml::reader;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::Path;
// use std::fs::{File, OpenOptions};
// use std::io::BufReader;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
// FIXME: Make it a Diesel model
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
/// not `RSS` related sub-elements are ommited.
///
/// [spec]: http://dev.opml.org/spec2.html
pub struct Opml {
title: String,
description: String,
url: String,
}
/// Import feed url's from a `R` into the `Source` table.
// TODO: Write test
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
let feeds = extract_sources(reader)?
.iter()
.map(|opml| Source::from_url(&opml.url))
.filter_map(|s| {
if let Err(ref err) = s {
let txt = "If you think this might be a bug please consider filling a report over \
at https://gitlab.gnome.org/World/hammond/issues/new";
error!("Failed to import a Show: {}", err);
error!("{}", txt);
}
s.ok()
})
.collect();
Ok(feeds)
}
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
/// return the new `Source`s
// TODO: Write test
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
let content = fs::read(path)?;
import_to_db(content.as_slice()).map_err(From::from)
}
/// Extracts the `outline` elemnts from a reader `R` and returns a `HashSet` of `Opml` structs.
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
let mut list = HashSet::new();
let parser = reader::EventReader::new(reader);
parser
.into_iter()
.map(|e| match e {
Ok(reader::XmlEvent::StartElement {
name, attributes, ..
}) => {
if name.local_name == "outline" {
let mut title = String::new();
let mut url = String::new();
let mut description = String::new();
attributes.into_iter().for_each(|attribute| {
match attribute.name.local_name.as_str() {
"title" => title = attribute.value,
"xmlUrl" => url = attribute.value,
"description" => description = attribute.value,
_ => {}
}
});
let feed = Opml {
title,
description,
url,
};
list.insert(feed);
}
Ok(())
}
Err(err) => Err(err),
_ => Ok(()),
})
.collect::<Result<Vec<_>, reader::Error>>()?;
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
#[test]
fn test_extract() {
let int_title = String::from("Intercepted with Jeremy Scahill");
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
let int_desc =
String::from(
"The people behind The Intercepts fearless reporting and incisive \
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss the \
crucial issues of our time: national security, civil liberties, foreign policy, \
and criminal justice. Plus interviews with artists, thinkers, and newsmakers \
who challenge our preconceptions about the world we live in.",
);
let dec_title = String::from("Deconstructed with Mehdi Hasan");
let dec_url = String::from("https://rss.prod.firstlook.media/deconstructed/podcast.rss");
let dec_desc = String::from(
"Journalist Mehdi Hasan is known around the world for his televised takedowns of \
presidents and prime ministers. In this new podcast from The Intercept, Mehdi \
unpacks a game-changing news event of the week while challenging the conventional \
wisdom. As a Brit, a Muslim and an immigrant based in Donald Trump's Washington \
D.C., Mehdi gives a refreshingly provocative perspective on the ups and downs of \
American—and global—politics.",
);
#[cfg_attr(rustfmt, rustfmt_skip)]
let sample1 = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
<opml version=\"2.0\"> \
<head> \
<title>Test OPML File</title> \
<dateCreated>{}</dateCreated> \
<docs>http://www.opml.org/spec2</docs> \
</head> \
<body> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
</body> \
</opml>",
Local::now().format("%a, %d %b %Y %T %Z"),
int_title,
int_desc,
int_url,
dec_title,
dec_desc,
dec_url,
);
let map = hashset![
Opml {
title: int_title,
description: int_desc,
url: int_url
},
Opml {
title: dec_title,
description: dec_desc,
url: dec_url
},
];
assert_eq!(extract_sources(sample1.as_bytes()).unwrap(), map);
}
}
+74 -143
View File
@@ -2,186 +2,117 @@
//! Docs.
use futures::future::*;
// use futures::prelude::*;
use futures::prelude::*;
use futures::stream::*;
use hyper::Client;
use hyper::client::HttpConnector;
use hyper::Client;
use hyper_tls::HttpsConnector;
use tokio_core::reactor::Core;
use num_cpus;
use rss;
use rayon;
use rayon_futures::ScopeFutureExt;
use Source;
use dbqueries;
use errors::DataError;
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
use Source;
// use std::sync::{Arc, Mutex};
// http://gtk-rs.org/tuto/closures
#[macro_export]
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`.
///
/// Messy temp diagram:
/// Source -> GET Request -> Update Etags -> Check Status -> Parse xml/Rss ->
/// Convert `rss::Channel` into Feed -> Index Podcast -> Index Episodes.
pub fn pipeline<S: IntoIterator<Item = Source>>(
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` ->
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
pub fn pipeline<'a, S>(
sources: S,
ignore_etags: bool,
tokio_core: &mut Core,
client: Client<HttpsConnector<HttpConnector>>,
) -> Result<(), DataError> {
let list: Vec<_> = sources
.into_iter()
.map(move |s| s.into_feed(&client, ignore_etags))
.map(|fut| fut.and_then(|feed| feed.index()))
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
.collect();
if list.is_empty() {
return Err(DataError::EmptyFuturesList);
}
// Thats not really concurrent yet I think.
tokio_core.run(collect_futures(list))?;
Ok(())
client: &HttpsClient,
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a
where
S: Stream<Item = Source, Error = DataError> + 'a,
{
sources
.and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags)))
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index())))
// the stream will stop at the first error so
// we ensure that everything will succeded regardless.
.map_err(|err| error!("Error: {}", err))
.then(|_| ok::<(), DataError>(()))
.collect()
}
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
/// runs the pipeline.
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
if sources.is_empty() {
return Ok(());
}
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
pipeline(sources, ignore_etags, &mut core, client)
}
/// Docs
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
let work = s.into_feed(&client, ignore_etags)
.and_then(move |feed| feed.index())
.map(|_| ());
core.run(work)
}
fn determine_ep_state(
ep: NewEpisodeMinimal,
item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
if !exists {
Ok(IndexState::Index(ep.into_new_episode(item)))
} else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
let rowid = old.rowid();
if ep != old {
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
} else {
Ok(IndexState::NotChanged)
}
}
}
pub(crate) fn glue_async<'a>(
item: &'a rss::Item,
id: i32,
) -> Box<Future<Item = IndexState<NewEpisode>, Error = DataError> + 'a> {
Box::new(
result(NewEpisodeMinimal::new(item, id)).and_then(move |ep| determine_ep_state(ep, item)),
)
}
// Weird magic from #rust irc channel
// kudos to remexre
/// FIXME: Docs
#[cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
pub fn collect_futures<F>(
futures: Vec<F>,
) -> Box<Future<Item = Vec<Result<F::Item, F::Error>>, Error = DataError>>
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
pub fn run<S>(sources: S, ignore_etags: bool) -> Result<(), DataError>
where
F: 'static + Future,
<F as Future>::Item: 'static,
<F as Future>::Error: 'static,
S: IntoIterator<Item = Source>,
{
Box::new(loop_fn((futures, vec![]), |(futures, mut done)| {
select_all(futures).then(|r| {
let (r, rest) = match r {
Ok((r, _, rest)) => (Ok(r), rest),
Err((r, _, rest)) => (Err(r), rest),
};
done.push(r);
if rest.is_empty() {
Ok(Loop::Break(done))
} else {
Ok(Loop::Continue((rest, done)))
}
})
}))
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
let stream = iter_ok::<_, DataError>(sources);
let p = pipeline(stream, ignore_etags, &client);
core.run(p).map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use Source;
use database::truncate_db;
use dbqueries;
use Source;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
const URLS: &[&str] = &[
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
];
#[test]
/// Insert feeds and update/index them.
fn test_pipeline() {
truncate_db().unwrap();
URLS.iter().for_each(|&(_, url)| {
let bad_url = "https://gitlab.gnome.org/World/hammond.atom";
// if a stream returns error/None it stops
// bad we want to parse all feeds regardless if one fails
Source::from_url(bad_url).unwrap();
URLS.iter().for_each(|url| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
let sources = dbqueries::get_sources().unwrap();
run(sources, true).unwrap();
@@ -190,7 +121,7 @@ mod tests {
run(sources, true).unwrap();
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_sources().unwrap().len(), 6);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
+29
View File
@@ -0,0 +1,29 @@
diff --git a/hammond-data/src/schema.rs b/hammond-data/src/schema.rs
index 03cbed0..88f1622 100644
--- a/hammond-data/src/schema.rs
+++ b/hammond-data/src/schema.rs
@@ -1,8 +1,11 @@
+#![allow(warnings)]
+
table! {
episodes (title, show_id) {
+ rowid -> Integer,
title -> Text,
uri -> Nullable<Text>,
local_uri -> Nullable<Text>,
description -> Nullable<Text>,
epoch -> Integer,
length -> Nullable<Integer>,
@@ -30,11 +33,7 @@ table! {
uri -> Text,
last_modified -> Nullable<Text>,
http_etag -> Nullable<Text>,
}
}
-allow_tables_to_appear_in_same_query!(
- episodes,
- shows,
- source,
-);
+allow_tables_to_appear_in_same_query!(episodes, shows, source);
+6 -9
View File
@@ -1,5 +1,7 @@
#![allow(warnings)]
table! {
episode (title, podcast_id) {
episodes (title, show_id) {
rowid -> Integer,
title -> Text,
uri -> Nullable<Text>,
@@ -10,22 +12,17 @@ table! {
duration -> Nullable<Integer>,
guid -> Nullable<Text>,
played -> Nullable<Integer>,
favorite -> Bool,
archive -> Bool,
podcast_id -> Integer,
show_id -> Integer,
}
}
table! {
podcast (id) {
shows (id) {
id -> Integer,
title -> Text,
link -> Text,
description -> Text,
image_uri -> Nullable<Text>,
favorite -> Bool,
archive -> Bool,
always_dl -> Bool,
source_id -> Integer,
}
}
@@ -39,4 +36,4 @@ table! {
}
}
allow_tables_to_appear_in_same_query!(episode, podcast, source,);
allow_tables_to_appear_in_same_query!(episodes, shows, source);
+19 -20
View File
@@ -7,7 +7,7 @@ use url::{Position, Url};
use dbqueries;
use errors::DataError;
use models::{EpisodeCleanerQuery, Podcast, Save};
use models::{EpisodeCleanerModel, Save, Show};
use xdg_dirs::DL_DIR;
use std::fs;
@@ -28,10 +28,10 @@ fn download_checker() -> Result<(), DataError> {
})
.for_each(|ep| {
ep.set_local_uri(None);
if let Err(err) = ep.save() {
error!("Error while trying to update episode: {:#?}", ep);
error!("{}", err);
};
ep.save()
.map_err(|err| error!("{}", err))
.map_err(|_| error!("Error while trying to update episode: {:#?}", ep))
.ok();
});
Ok(())
@@ -48,19 +48,18 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
.for_each(|ep| {
let limit = ep.played().unwrap();
if now_utc > limit {
if let Err(err) = delete_local_content(ep) {
error!("Error while trying to delete file: {:?}", ep.local_uri());
error!("{}", err);
} else {
info!("Episode {:?} was deleted succesfully.", ep.local_uri());
};
delete_local_content(ep)
.map(|_| info!("Episode {:?} was deleted succesfully.", ep.local_uri()))
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri()))
.ok();
}
});
Ok(())
}
/// Check `ep.local_uri` field and delete the file it points to.
fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
fn delete_local_content(ep: &mut EpisodeCleanerModel) -> Result<(), DataError> {
if ep.local_uri().is_some() {
let uri = ep.local_uri().unwrap().to_owned();
if Path::new(&uri).exists() {
@@ -109,9 +108,9 @@ pub fn url_cleaner(s: &str) -> String {
}
}
/// Returns the URI of a Podcast Downloads given it's title.
/// Returns the URI of a Show Downloads given it's title.
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
// It might be better to make it a hash of the title or the podcast rowid
// It might be better to make it a hash of the title or the Show rowid
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
// Create the folder
@@ -124,7 +123,7 @@ pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
/// Removes all the entries associated with the given show from the database,
/// and deletes all of the downloaded content.
// TODO: Write Tests
pub fn delete_show(pd: &Podcast) -> Result<(), DataError> {
pub fn delete_show(pd: &Show) -> Result<(), DataError> {
dbqueries::remove_feed(pd)?;
info!("{} was removed succesfully.", pd.title());
@@ -184,7 +183,7 @@ mod tests {
// Setup episodes
let n1 = NewEpisodeBuilder::default()
.title("foo_bar".to_string())
.podcast_id(0)
.show_id(0)
.build()
.unwrap()
.to_episode()
@@ -192,14 +191,14 @@ mod tests {
let n2 = NewEpisodeBuilder::default()
.title("bar_baz".to_string())
.podcast_id(1)
.show_id(1)
.build()
.unwrap()
.to_episode()
.unwrap();
let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.podcast_id()).unwrap();
let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.podcast_id()).unwrap();
let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.show_id()).unwrap();
let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.show_id()).unwrap();
ep1.set_local_uri(Some(valid_path.to_str().unwrap()));
ep2.set_local_uri(Some(bad_path.to_str().unwrap()));
@@ -231,7 +230,7 @@ mod tests {
#[test]
fn test_download_cleaner() {
let _tmp_dir = helper_db();
let mut episode: EpisodeCleanerQuery =
let mut episode: EpisodeCleanerModel =
dbqueries::get_episode_from_pk("foo_bar", 0).unwrap().into();
let valid_path = episode.local_uri().unwrap().to_owned();
+4 -4
View File
@@ -5,11 +5,11 @@ version = "0.1.0"
workspace = "../"
[dependencies]
error-chain = "0.11.0"
hyper = "0.11.24"
log = "0.4.1"
error-chain = "0.12.0"
hyper = "0.11.27"
log = "0.4.2"
mime_guess = "1.8.4"
reqwest = "0.8.5"
reqwest = "0.8.6"
tempdir = "0.3.7"
glob = "0.2.11"
failure = "0.1.1"
+9 -7
View File
@@ -11,8 +11,8 @@ use std::io::{BufWriter, Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
use hammond_data::xdg_dirs::HAMMOND_CACHE;
use hammond_data::{EpisodeWidgetModel, Save, ShowCoverModel};
// use failure::Error;
use errors::DownloadError;
@@ -127,7 +127,7 @@ fn save_io(
info!("Downloading into: {}", file);
let chunk_size = match content_lenght {
Some(x) => x as usize / 99,
None => 1024 as usize, // default chunk size
None => 1024, // default chunk size
};
let mut writer = BufWriter::new(File::create(&file)?);
@@ -161,7 +161,7 @@ fn save_io(
// TODO: Refactor
pub fn get_episode(
ep: &mut EpisodeWidgetQuery,
ep: &mut EpisodeWidgetModel,
download_folder: &str,
progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<(), DownloadError> {
@@ -196,8 +196,9 @@ pub fn get_episode(
Ok(())
}
pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
let url = pd.image_uri()
pub fn cache_image(pd: &ShowCoverModel) -> Result<String, DownloadError> {
let url = pd
.image_uri()
.ok_or_else(|| DownloadError::NoImageLocation)?
.to_owned();
@@ -215,7 +216,8 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
// For some reason there is no .first() method so nth(0) is used
let path = foo.nth(0).and_then(|x| x.ok());
if let Some(p) = path {
return Ok(p.to_str()
return Ok(p
.to_str()
.ok_or_else(|| DownloadError::InvalidCachedImageLocation)?
.into());
}
@@ -234,9 +236,9 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
#[cfg(test)]
mod tests {
use super::*;
use hammond_data::Source;
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::Source;
use std::fs;
+11 -2
View File
@@ -1,7 +1,16 @@
#![recursion_limit = "1024"]
#![deny(unused_extern_crates, unused)]
#![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name, option_map_unit_fn))]
// Enable lint group collections
#![warn(nonstandard_style, edition_2018, rust_2018_idioms, bad_style, unused)]
// standalone lints
#![warn(
const_err, improper_ctypes, non_shorthand_field_patterns, no_mangle_generic_items,
overflowing_literals, plugin_as_library, private_no_mangle_fns, private_no_mangle_statics,
unconditional_recursion, unions_with_drop_fields, while_true, missing_debug_implementations,
trivial_casts, trivial_numeric_casts, elided_lifetime_in_paths, missing_copy_implementations
)]
#![deny(warnings)]
extern crate failure;
#[macro_use]
+13 -11
View File
@@ -6,33 +6,35 @@ version = "0.1.0"
workspace = "../"
[dependencies]
chrono = "0.4.1"
chrono = "0.4.4"
crossbeam-channel = "0.2.1"
gdk = "0.8.0"
gdk-pixbuf = "0.4.0"
glib = "0.5.0"
gstreamer = "0.11.3"
gstreamer-player = "0.11.3"
humansize = "1.1.0"
lazy_static = "1.0.0"
log = "0.4.1"
lazy_static = "1.0.1"
log = "0.4.2"
loggerv = "0.7.1"
open = "1.2.1"
rayon = "1.0.1"
send-cell = "0.1.2"
send-cell = "0.1.3"
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" }
regex = "1.0.1"
reqwest = "0.8.6"
serde_json = "1.0.22"
html2text = "0.1.7"
[dependencies.gtk]
features = ["v3_22"]
version = "0.4.0"
version = "0.4.1"
[dependencies.gio]
features = ["v2_50"]
version = "0.4.0"
version = "0.4.1"
[dependencies.hammond-data]
path = "../hammond-data"
+4
View File
@@ -1,6 +1,10 @@
use std::process::Command;
fn main() {
// Rerun the build script when files in the resources folder are changed.
println!("cargo:rerun-if-changed=resources");
println!("cargo:rerun-if-changed=resources/*");
Command::new("glib-compile-resources")
.args(&["--generate", "resources.xml"])
.current_dir("resources")
+19 -30
View File
@@ -41,37 +41,26 @@ Tobias Bernard
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<object class="GtkBox" id="info_container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkBox">
<object class="GtkLabel" id="title_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<property name="lines">1</property>
<property name="xalign">0</property>
<property name="yalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</property>
<property name="width_chars">55</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<property name="lines">1</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
@@ -213,16 +202,16 @@ Tobias Bernard
</packing>
</child>
<child>
<object class="GtkBox">
<object class="GtkButtonBox" id="button_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<property name="layout_style">center</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
@@ -237,8 +226,8 @@ Tobias Bernard
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Download this episode</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="always_show_image">True</property>
<child>
@@ -252,8 +241,8 @@ Tobias Bernard
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
<property name="position">1</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
<child>
@@ -262,6 +251,7 @@ Tobias Bernard
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Play this episode</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
@@ -274,15 +264,14 @@ Tobias Bernard
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">3</property>
<property name="position">2</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="padding">6</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
+3 -9
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -32,13 +32,14 @@ Tobias Bernard
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="name">container</property>
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="height_request">400</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
@@ -68,8 +69,6 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox" id="frame_parent">
<property name="width_request">600</property>
<property name="height_request">-1</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">32</property>
@@ -81,7 +80,6 @@ Tobias Bernard
<property name="spacing">24</property>
<child>
<object class="GtkBox" id="today_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
@@ -139,7 +137,6 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox" id="yday_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
@@ -196,7 +193,6 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox" id="week_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
@@ -253,7 +249,6 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox" id="month_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
@@ -310,7 +305,6 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox" id="rest_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
+5 -99
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
@@ -151,9 +151,10 @@ Tobias Bernard
<object class="GtkMenuButton" id="add_toggle">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Add a new feed</property>
<property name="valign">center</property>
<property name="popover">add_popover</property>
<child>
<object class="GtkImage" id="add-button-image2">
<property name="visible">True</property>
@@ -168,7 +169,7 @@ Tobias Bernard
</object>
</child>
<child>
<object class="GtkButton" id="back_button">
<object class="GtkButton" id="back">
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
@@ -257,12 +258,11 @@ Tobias Bernard
</object>
</child>
<child>
<object class="GtkMenuButton" id="menu_toggle">
<object class="GtkMenuButton" id="menu_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="valign">center</property>
<property name="popover">menu_popover</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
@@ -281,98 +281,4 @@ Tobias Bernard
</packing>
</child>
</object>
<object class="GtkPopover" id="menu_popover">
<property name="can_focus">False</property>
<property name="relative_to">menu_toggle</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Preferences</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="update_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Check for new episodes</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="about_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">About</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Help</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">7</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Keyboard Shortcuts</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">8</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<object class="GtkShortcutsWindow" id="help_overlay">
<property name="modal">True</property>
<child>
<object class="GtkShortcutsSection">
<property name="section-name">shortcuts</property>
<property name="max-height">12</property>
<property name="visible">True</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes">General</property>
<property name="visible">True</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">True</property>
<property name="accelerator">&lt;primary&gt;r</property>
<property name="title" translatable="yes" context="shortcut window">Check for new episodes</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">True</property>
<property name="accelerator">&lt;primary&gt;q</property>
<property name="title" translatable="yes" context="shortcut window">Quit the application</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<interface>
<!-- interface-requires gtk+ 3.0 -->
<menu id="menu">
<section>
<item>
<attribute name="label" translatable="yes">_Check for new episodes</attribute>
<attribute name="action">win.refresh</attribute>
<attribute name="accel">&lt;primary&gt;r</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Import Shows</attribute>
<attribute name="action">win.import</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Export Shows</attribute>
<attribute name="action">win.export</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Preferences</attribute>
<attribute name="action">win.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
<attribute name="action">win.show-help-overlay</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Help</attribute>
<attribute name="action">win.help</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_About</attribute>
<attribute name="action">win.about</attribute>
</item>
</section>
</menu>
</interface>
+372
View File
@@ -0,0 +1,372 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkImage" id="ff_image">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">False</property>
<property name="margin_top">1</property>
<property name="icon_name">media-seek-forward-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="pause_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_top">1</property>
<property name="icon_name">media-playback-pause-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="play_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_top">1</property>
<property name="icon_name">media-playback-start-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="previous_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_top">1</property>
<property name="icon_name">media-seek-backward-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkActionBar" id="action_bar">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="valign">center</property>
<child>
<object class="GtkBox" id="buttons">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<child>
<object class="GtkButton" id="rewind_button">
<property name="width_request">42</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Previous</property>
<property name="image">previous_image</property>
<property name="always_show_image">True</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="play_button">
<property name="width_request">60</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Play</property>
<property name="image">play_image</property>
<property name="always_show_image">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="pause_button">
<property name="width_request">60</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Play</property>
<property name="image">pause_image</property>
<property name="always_show_image">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="ff_button">
<property name="width_request">42</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Next</property>
<property name="image">ff_image</property>
<property name="always_show_image">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
</packing>
</child>
<style>
<class name="linked"/>
</style>
</object>
<packing>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="info">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="show_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="pixel_size">34</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="show_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Show Title</property>
<property name="wrap">True</property>
<property name="ellipsize">end</property>
<property name="max_width_chars">20</property>
<style>
<class name="player-show-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" id="episode_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Episode Title</property>
<property name="wrap">True</property>
<property name="ellipsize">end</property>
<property name="max_width_chars">20</property>
<style>
<class name="player-episode-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScale" id="seek">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hexpand">True</property>
<property name="round_digits">1</property>
<property name="draw_value">False</property>
</object>
<packing>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="timer">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="progress_time_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="label">0:00</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="separator">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="label">/</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="total_duration_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="label">0:00</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkMenuButton" id="rate_button">
<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>
<property name="margin_left">6</property>
<property name="direction">up</property>
<property name="popover">rate_popover</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="rate_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">1.00x</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">go-down-symbolic</property>
<property name="icon_size">1</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
</object>
<packing>
<property name="position">4</property>
</packing>
</child>
</object>
<object class="GtkPopover" id="rate_popover">
<property name="can_focus">False</property>
<property name="relative_to">rate_button</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>
<property name="spacing">3</property>
<child>
<object class="GtkRadioButton" id="rate_1_50">
<property name="label" translatable="yes">1.50x</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="draw_indicator">True</property>
<property name="group">normal_rate</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="rate_1_25">
<property name="label" translatable="yes">1.25x</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="draw_indicator">True</property>
<property name="group">normal_rate</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="normal_rate">
<property name="label" translatable="yes">1.00x</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="active">True</property>
<property name="draw_indicator">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
+93 -88
View File
@@ -31,16 +31,17 @@ Tobias Bernard
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="width_request">700</property>
<property name="height_request">500</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="vexpand">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
@@ -70,26 +71,26 @@ Tobias Bernard
</child>
<child>
<object class="GtkBox">
<property name="width_request">700</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_top">32</property>
<property name="margin_bottom">32</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="spacing">12</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
@@ -98,107 +99,107 @@ Tobias Bernard
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<property name="min_content_height">80</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label">This is embarrasing!
Sorry, we could not find a description for this Show.</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max_width_chars">70</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="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">
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="spacing">6</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkMenuButton" id="settings_button">
<object class="GtkImage">
<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="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="link_button">
<property name="label" translatable="yes">Website</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
@@ -209,13 +210,17 @@ Tobias Bernard
</packing>
</child>
<child>
<object class="GtkFrame" id="episodes">
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<placeholder/>
<object class="GtkListBox" id="episodes">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
@@ -230,7 +235,7 @@ Tobias Bernard
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
@@ -257,7 +262,7 @@ Tobias Bernard
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
+8 -17
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
@@ -39,26 +39,17 @@ Tobias Bernard
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkOverlay">
<object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<placeholder/>
</child>
<child type="overlay">
<object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
<property name="icon_size">0</property>
</object>
</child>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
<property name="icon_size">0</property>
</object>
<packing>
<property name="expand">True</property>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
+9
View File
@@ -9,3 +9,12 @@ row:last-child {
list, border {
border-radius: 4px;
}
.player-episode-label {
font-weight: bold;
font-size: smaller;
}
.player-show-label {
font-size: smaller;
}
@@ -5,32 +5,32 @@
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<developer_name>Jordan Petridis</developer_name>
<summary>Modern Podcast Client for the GNOME desktop</summary>
<summary>Modern Podcast application for the GNOME desktop</summary>
<description xml:lang="en">
<p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
Hammond is a modern, reliable, and fast Podcast application for the GNOME
desktop written in Rust.
</p>
</description>
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<screenshots>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/episodes_view.png</image>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/shows_view.png</image>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/show_widget.png</image>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.3.1" date="2018-03-28"/>
<release version="0.4.0" date="2018-07-04"/>
</releases>
<url type="homepage">https://gitlab.gnome.org/alatiera/hammond</url>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name>
</component>
+4 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/hammond/">
<gresource prefix="/org/gnome/Hammond/">
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
@@ -11,6 +11,9 @@
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/menus.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/player_toolbar.ui</file>
<file compressed="true">gtk/style.css</file>
</gresource>
</gresources>
+208 -176
View File
@@ -1,38 +1,49 @@
#![allow(new_without_default)]
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt};
use gio::{
self, ActionMapExt, ApplicationExt, ApplicationExtManual, ApplicationFlags, SettingsExt,
SimpleAction, SimpleActionExt,
};
use glib;
use gtk;
use gtk::SettingsExt as GtkSettingsExt;
use gtk::prelude::*;
use gtk::SettingsExt as GtkSettingsExt;
use failure::Error;
use rayon;
use crossbeam_channel::{unbounded, Receiver, Sender};
use hammond_data::Show;
use hammond_data::{Podcast, Source};
use hammond_data::utils::delete_show;
use appnotif::*;
use headerbar::Header;
use settings::WindowGeometry;
use stacks::Content;
use settings::{self, WindowGeometry};
use stacks::{Content, PopulatedState};
use utils;
use widgets::mark_all_watched;
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::player;
use widgets::{about_dialog, mark_all_notif, remove_show_notif};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration;
#[derive(Clone, Debug)]
/// Creates an action named $called in the action map $on with the handler $handle
macro_rules! action {
($on:expr, $called:expr, $handle:expr) => {{
// Create a stateless, parameterless action
let act = SimpleAction::new($called, None);
// Connect the handler
act.connect_activate($handle);
// Add it to the map
$on.add_action(&act);
// Return the action
act
}};
}
#[derive(Debug, Clone)]
pub enum Action {
UpdateSources(Option<Source>),
RefreshAllViews,
RefreshEpisodesView,
RefreshEpisodesViewBGR,
RefreshShowsView,
RefreshWidget,
RefreshWidgetIfVis,
ReplaceWidget(Arc<Podcast>),
ReplaceWidget(Arc<Show>),
RefreshWidgetIfSame(i32),
ShowWidgetAnimated,
ShowShowsAnimated,
@@ -40,72 +51,91 @@ pub enum Action {
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>),
MarkAllPlayerNotification(Arc<Show>),
RemoveShow(Arc<Show>),
ErrorNotification(String),
InitEpisode(i32),
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct App {
app_instance: gtk::Application,
window: gtk::Window,
instance: gtk::Application,
window: gtk::ApplicationWindow,
overlay: gtk::Overlay,
header: Arc<Header>,
content: Arc<Content>,
receiver: Receiver<Action>,
settings: gio::Settings,
content: Rc<Content>,
headerbar: Rc<Header>,
player: Rc<player::PlayerWidget>,
sender: Sender<Action>,
settings: Settings,
receiver: Receiver<Action>,
}
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...");
pub fn new(application: &gtk::Application) -> Rc<Self> {
let settings = gio::Settings::new("org.gnome.Hammond");
// Weird magic I copy-pasted that sets the Application Name in the Shell.
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
// Create the main window
let window = gtk::Window::new(gtk::WindowType::Toplevel);
let (sender, receiver) = unbounded();
let window = gtk::ApplicationWindow::new(application);
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();
window.connect_delete_event(clone!(application, settings => move |window, _| {
WindowGeometry::from_window(&window).write(&settings);
application.quit();
Inhibit(false)
});
let (sender, receiver) = channel();
}));
// Create a content instance
let content =
Arc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
let content = Content::new(&sender).expect("Content Initialization failed.");
// Create the headerbar
let header = Arc::new(Header::new(&content, &window, sender.clone()));
let header = Header::new(&content, &sender);
// Add the Headerbar to the window.
window.set_titlebar(&header.container);
// 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);
let wrap = gtk::Box::new(gtk::Orientation::Vertical, 0);
// Add the overlay to the main Box
wrap.add(&overlay);
App {
app_instance: application,
let player = player::PlayerWidget::new(&sender);
// Add the player to the main Box
wrap.add(&player.action_bar);
window.add(&wrap);
let app = App {
instance: application.clone(),
window,
overlay,
header,
content,
receiver,
sender,
settings,
}
overlay,
headerbar: header,
content,
player,
sender,
receiver,
};
Rc::new(app)
}
fn init(app: &Rc<Self>) {
let cleanup_date = settings::get_cleanup_date(&app.settings);
// Garbage collect watched episodes from the disk
utils::cleanup(cleanup_date);
app.setup_gactions();
app.setup_timed_callbacks();
app.instance.connect_activate(move |_| ());
// Retrieve the previous window position and size.
WindowGeometry::from_settings(&app.settings).apply(&app.window);
// Setup the Action channel
gtk::timeout_add(25, clone!(app => move || app.setup_action_channel()));
}
fn setup_timed_callbacks(&self) {
@@ -115,153 +145,155 @@ impl App {
}
fn setup_dark_theme(&self) {
let settings = gtk::Settings::get_default().unwrap();
let gtk_settings = gtk::Settings::get_default().unwrap();
let enabled = self.settings.get_boolean("dark-theme");
settings.set_property_gtk_application_prefer_dark_theme(enabled);
gtk_settings.set_property_gtk_application_prefer_dark_theme(enabled);
}
fn setup_refresh_on_startup(&self) {
// Update the feeds right after the Application is initialized.
let sender = self.sender.clone();
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());
// The ui loads async, after initialization
// so we need to delay this a bit so it won't block
// requests that will come from loading the gui on startup.
gtk::timeout_add(1500, move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
});
}
}
fn setup_auto_refresh(&self) {
let refresh_interval = utils::get_refresh_interval(&self.settings).num_seconds() as u32;
let sender = self.sender.clone();
let refresh_interval = settings::get_refresh_interval(&self.settings).num_seconds() as u32;
info!("Auto-refresh every {:?} seconds.", refresh_interval);
let sender = self.sender.clone();
gtk::timeout_add_seconds(refresh_interval, move || {
utils::refresh(None, sender.clone());
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(true)
});
}
pub fn run(self) {
WindowGeometry::from_settings(&self.settings).apply(&self.window);
/// Define the `GAction`s.
///
/// Used in menus and the keyboard shortcuts dialog.
#[cfg_attr(rustfmt, rustfmt_skip)]
fn setup_gactions(&self) {
let sender = &self.sender;
let win = &self.window;
let instance = &self.instance;
let header = &self.headerbar;
let window = self.window.clone();
// Create the `refresh` action.
//
// This will trigger a refresh of all the shows in the database.
action!(win, "refresh", clone!(sender => move |_, _| {
gtk::idle_add(clone!(sender => move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
}));
}));
self.instance.set_accels_for_action("win.refresh", &["<primary>r"]);
self.app_instance.connect_startup(move |app| {
build_ui(&window, app);
});
self.setup_timed_callbacks();
// Create the `OPML` import action
action!(win, "import", clone!(sender, win => move |_, _| {
utils::on_import_clicked(&win, &sender)
}));
let content = self.content.clone();
let headerbar = self.header.clone();
let sender = self.sender.clone();
let overlay = self.overlay.clone();
let receiver = self.receiver;
gtk::idle_add(move || {
match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(Action::UpdateSources(source)) => {
if let Some(s) = source {
utils::refresh(Some(vec![s]), sender.clone());
} else {
utils::refresh(None, sender.clone());
}
// Create the action that shows a `gtk::AboutDialog`
action!(win, "about", clone!(win => move |_, _| about_dialog(&win)));
// Create the quit action
action!(win, "quit", clone!(instance => move |_, _| instance.quit()));
self.instance.set_accels_for_action("win.quit", &["<primary>q"]);
// Create the menu action
action!(win, "menu",clone!(header => move |_, _| header.open_menu()));
// Bind the hamburger menu button to `F10`
self.instance.set_accels_for_action("win.menu", &["F10"]);
}
fn setup_action_channel(&self) -> glib::Continue {
if let Some(action) = self.receiver.try_recv() {
trace!("Incoming channel action: {:?}", action);
match action {
Action::RefreshAllViews => self.content.update(),
Action::RefreshShowsView => self.content.update_shows_view(),
Action::RefreshWidgetIfSame(id) => self.content.update_widget_if_same(id),
Action::RefreshEpisodesView => self.content.update_home(),
Action::RefreshEpisodesViewBGR => self.content.update_home_if_background(),
Action::ReplaceWidget(pd) => {
let shows = self.content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.replace_widget(pd.clone())
.map_err(|err| error!("Failed to update ShowWidget: {}", err))
.map_err(|_| error!("Failed ot update ShowWidget {}", pd.title()))
.ok();
}
Ok(Action::RefreshAllViews) => content.update(),
Ok(Action::RefreshShowsView) => content.update_shows_view(),
Ok(Action::RefreshWidget) => content.update_widget(),
Ok(Action::RefreshWidgetIfVis) => content.update_widget_if_visible(),
Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id),
Ok(Action::RefreshEpisodesView) => content.update_episode_view(),
Ok(Action::RefreshEpisodesViewBGR) => content.update_episode_view_if_baground(),
Ok(Action::ReplaceWidget(pd)) => {
if let Err(err) = content.get_shows().replace_widget(pd) {
error!("Something went wrong while trying to update the ShowWidget.");
error!("Error: {}", err);
}
Action::ShowWidgetAnimated => {
let shows = self.content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut().switch_visible(
PopulatedState::Widget,
gtk::StackTransitionType::SlideLeft,
);
}
Ok(Action::ShowWidgetAnimated) => content.get_shows().switch_widget_animated(),
Ok(Action::ShowShowsAnimated) => content.get_shows().switch_podcasts_animated(),
Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
Ok(Action::MarkAllPlayerNotification(pd)) => {
let callback = clone!(sender => move || {
if let Err(err) = mark_all_watched(&pd, sender.clone()) {
error!("Something went horribly wrong with the notif callback: {}", err);
}
glib::Continue(false)
});
let text = "Marked all episodes as listened".into();
let notif = InAppNotification::new(text, callback, || {}, sender.clone());
notif.show(&overlay);
Action::ShowShowsAnimated => {
let shows = self.content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.switch_visible(PopulatedState::View, gtk::StackTransitionType::SlideRight);
}
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);
Action::HeaderBarShowTile(title) => self.headerbar.switch_to_back(&title),
Action::HeaderBarNormal => self.headerbar.switch_to_normal(),
Action::HeaderBarShowUpdateIndicator => self.headerbar.show_update_notification(),
Action::HeaderBarHideUpdateIndicator => self.headerbar.hide_update_notification(),
Action::MarkAllPlayerNotification(pd) => {
let notif = mark_all_notif(pd, &self.sender);
notif.show(&self.overlay);
}
Err(_) => (),
Action::RemoveShow(pd) => {
let notif = remove_show_notif(pd, self.sender.clone());
notif.show(&self.overlay);
}
Action::ErrorNotification(err) => {
error!("An error notification was triggered: {}", err);
let callback = || glib::Continue(false);
let notif = InAppNotification::new(&err, callback, || {}, UndoState::Hidden);
notif.show(&self.overlay);
}
Action::InitEpisode(rowid) => self.player.initialize_episode(rowid).unwrap(),
}
}
Continue(true)
});
glib::Continue(true)
}
ApplicationExtManual::run(&self.app_instance, &[]);
pub fn run() {
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
.expect("Application Initialization failed...");
application.connect_startup(clone!(application => move |_| {
info!("CONNECT STARTUP RUN");
let app = Self::new(&application);
Self::init(&app);
app.window.show_all();
app.window.activate();
}));
// Weird magic I copy-pasted that sets the Application Name in the Shell.
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
// We need out own org.gnome.Hammon icon
gtk::Window::set_default_icon_name("multimedia-player");
ApplicationExtManual::run(&application, &[]);
}
}
fn build_ui(window: &gtk::Window, app: &gtk::Application) {
window.set_application(app);
window.show_all();
window.activate();
app.connect_activate(move |_| ());
}
+197 -192
View File
@@ -1,139 +1,243 @@
use gio::MenuModel;
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use failure::ResultExt;
use rayon;
use url::Url;
use hammond_data::Source;
use hammond_data::dbqueries;
use std::sync::mpsc::Sender;
use hammond_data::{dbqueries, Source};
use app::Action;
use stacks::Content;
use utils::itunes_to_rss;
use utils::{itunes_to_rss, refresh};
use std::rc::Rc;
#[derive(Debug, Clone)]
// TODO: Factor out the hamburger menu
// TODO: Make a proper state machine for the headerbar states
pub struct Header {
container: gtk::HeaderBar,
add_toggle: gtk::MenuButton,
pub container: gtk::HeaderBar,
switch: gtk::StackSwitcher,
back_button: gtk::Button,
back: gtk::Button,
show_title: gtk::Label,
about_button: gtk::ModelButton,
update_button: gtk::ModelButton,
update_box: gtk::Box,
update_label: gtk::Label,
update_spinner: gtk::Spinner,
menu_button: gtk::MenuButton,
app_menu: MenuModel,
updater: UpdateIndicator,
add: AddPopover,
}
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
#[derive(Debug, Clone)]
struct UpdateIndicator {
container: gtk::Box,
text: gtk::Label,
spinner: gtk::Spinner,
}
let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
let add_toggle: gtk::MenuButton = builder.get_object("add_toggle").unwrap();
let switch: gtk::StackSwitcher = builder.get_object("switch").unwrap();
let back_button: gtk::Button = builder.get_object("back_button").unwrap();
let show_title: gtk::Label = builder.get_object("show_title").unwrap();
let update_button: gtk::ModelButton = builder.get_object("update_button").unwrap();
let update_box: gtk::Box = builder.get_object("update_notification").unwrap();
let update_label: gtk::Label = builder.get_object("update_label").unwrap();
let update_spinner: gtk::Spinner = builder.get_object("update_spinner").unwrap();
let about_button: gtk::ModelButton = builder.get_object("about_button").unwrap();
impl UpdateIndicator {
fn show(&self) {
self.spinner.start();
self.spinner.show();
self.container.show();
self.text.show();
}
Header {
container: header,
add_toggle,
switch,
back_button,
show_title,
about_button,
update_button,
update_box,
update_label,
update_spinner,
fn hide(&self) {
self.spinner.stop();
self.spinner.hide();
self.container.hide();
self.text.hide();
}
}
#[derive(Debug, Clone)]
struct AddPopover {
container: gtk::Popover,
result: gtk::Label,
entry: gtk::Entry,
add: gtk::Button,
toggle: gtk::MenuButton,
}
impl AddPopover {
// FIXME: THIS ALSO SUCKS!
fn on_add_clicked(&self, sender: &Sender<Action>) -> Result<(), Error> {
let url = self
.entry
.get_text()
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
debug!("Url: {}", url);
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()
};
self.entry.set_text("");
rayon::spawn(clone!(sender => move || {
if let Ok(source) = Source::from_url(&url) {
refresh(Some(vec![source]), sender.clone());
} else {
error!("Failed to convert, url: {}, to a source entry", url);
}
}));
self.container.hide();
Ok(())
}
// FIXME: THIS SUCKS! REFACTOR ME.
fn on_entry_changed(&self) -> Result<(), Error> {
let uri = self
.entry
.get_text()
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
debug!("Url: {}", uri);
let url = Url::parse(&uri);
// TODO: refactor to avoid duplication
match url {
Ok(u) => {
if !dbqueries::source_exists(u.as_str())? {
self.add.set_sensitive(true);
self.result.hide();
self.result.set_label("");
} else {
self.add.set_sensitive(false);
self.result.set_label("Show already exists.");
self.result.show();
}
Ok(())
}
Err(err) => {
self.add.set_sensitive(false);
if !uri.is_empty() {
self.result.set_label("Invalid url.");
self.result.show();
error!("Error: {}", err);
} else {
self.result.hide();
}
Ok(())
}
}
}
}
// TODO: Refactor components into smaller state machines
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
let menus = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/menus.ui");
let header = builder.get_object("headerbar").unwrap();
let switch = builder.get_object("switch").unwrap();
let back = builder.get_object("back").unwrap();
let show_title = builder.get_object("show_title").unwrap();
let menu_button = builder.get_object("menu_button").unwrap();
let app_menu = menus.get_object("menu").unwrap();
let update_box = builder.get_object("update_notification").unwrap();
let update_label = builder.get_object("update_label").unwrap();
let update_spinner = builder.get_object("update_spinner").unwrap();
let updater = UpdateIndicator {
container: update_box,
text: update_label,
spinner: update_spinner,
};
let add_toggle = builder.get_object("add_toggle").unwrap();
let add_popover = builder.get_object("add_popover").unwrap();
let new_url = builder.get_object("new_url").unwrap();
let add_button = builder.get_object("add_button").unwrap();
let result = builder.get_object("result_label").unwrap();
let add = AddPopover {
container: add_popover,
entry: new_url,
toggle: add_toggle,
add: add_button,
result,
};
Header {
container: header,
switch,
back,
show_title,
menu_button,
app_menu,
updater,
add,
}
}
}
// TODO: Factor out the hamburger menu
// TODO: Make a proper state machine for the headerbar states
impl Header {
pub fn new(content: &Content, window: &gtk::Window, sender: Sender<Action>) -> Header {
let h = Header::default();
h.init(content, window, sender);
pub fn new(content: &Content, sender: &Sender<Action>) -> Rc<Self> {
let h = Rc::new(Header::default());
Self::init(&h, content, &sender);
h
}
pub fn init(&self, content: &Content, window: &gtk::Window, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
pub fn init(s: &Rc<Self>, content: &Content, sender: &Sender<Action>) {
let weak = Rc::downgrade(s);
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
let add_button: gtk::Button = builder.get_object("add_button").unwrap();
let result_label: gtk::Label = builder.get_object("result_label").unwrap();
self.switch.set_stack(&content.get_stack());
s.switch.set_stack(&content.get_stack());
new_url.connect_changed(clone!(add_button => move |url| {
if let Err(err) = on_url_change(url, &result_label, &add_button) {
error!("Error: {}", err);
}
s.add.entry.connect_changed(clone!(weak => move |_| {
weak.upgrade().map(|h| {
h.add.on_entry_changed()
.map_err(|err| error!("Error: {}", err))
.ok();
});
}));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
if let Err(err) = on_add_bttn_clicked(&new_url, sender.clone()) {
error!("Error: {}", err);
}
add_popover.hide();
s.add.add.connect_clicked(clone!(weak, sender => move |_| {
weak.upgrade().map(|h| h.add.on_add_clicked(&sender));
}));
self.add_toggle.set_popover(&add_popover);
self.update_button
.connect_clicked(clone!(sender => move |_| {
sender
.send(Action::UpdateSources(None))
.expect("Action channel blew up.");
}));
self.about_button
.connect_clicked(clone!(window => move |_| {
about_dialog(&window);
}));
// Add the Headerbar to the window.
window.set_titlebar(&self.container);
let switch = &self.switch;
let add_toggle = &self.add_toggle;
let show_title = &self.show_title;
self.back_button.connect_clicked(
clone!(switch, add_toggle, show_title, sender => move |back| {
let switch = &s.switch;
let add_toggle = &s.add.toggle;
let show_title = &s.show_title;
let menu = &s.menu_button;
s.back.connect_clicked(
clone!(switch, add_toggle, show_title, sender, menu => move |back| {
switch.show();
add_toggle.show();
back.hide();
show_title.hide();
if let Err(err) = sender.send(Action::ShowShowsAnimated) {
error!("Action channel blew up: {}", err);
}
menu.show();
sender.send(Action::ShowShowsAnimated);
}),
);
s.menu_button.set_menu_model(Some(&s.app_menu));
}
pub fn switch_to_back(&self, title: &str) {
self.switch.hide();
self.add_toggle.hide();
self.back_button.show();
self.add.toggle.hide();
self.back.show();
self.set_show_title(title);
self.show_title.show();
self.menu_button.hide();
}
pub fn switch_to_normal(&self) {
self.switch.show();
self.add_toggle.show();
self.back_button.hide();
self.add.toggle.show();
self.back.hide();
self.show_title.hide();
self.menu_button.show();
}
pub fn set_show_title(&self, title: &str) {
@@ -141,113 +245,14 @@ impl Header {
}
pub fn show_update_notification(&self) {
self.update_spinner.start();
self.update_box.show();
self.update_spinner.show();
self.update_label.show();
self.updater.show();
}
pub fn hide_update_notification(&self) {
self.update_spinner.stop();
self.update_box.hide();
self.update_spinner.hide();
self.update_label.hide();
self.updater.hide();
}
pub fn open_menu(&self) {
self.menu_button.clicked();
}
}
// FIXME: THIS ALSO SUCKS!
fn on_add_bttn_clicked(entry: &gtk::Entry, sender: Sender<Action>) -> Result<(), Error> {
let url = entry.get_text().unwrap_or_default();
let url = if url.contains("itunes.com") || url.contains("apple.com") {
info!("Detected itunes url.");
let foo = itunes_to_rss(&url)?;
info!("Resolved to {}", foo);
foo
} else {
url.to_owned()
};
let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?;
entry.set_text("");
sender
.send(Action::UpdateSources(Some(source)))
.context("App channel blew up.")?;
Ok(())
}
// FIXME: THIS SUCKS!
fn on_url_change(
entry: &gtk::Entry,
result: &gtk::Label,
add_button: &gtk::Button,
) -> Result<(), Error> {
let uri = entry
.get_text()
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
debug!("Url: {}", uri);
let url = Url::parse(&uri);
// TODO: refactor to avoid duplication
match url {
Ok(u) => {
if !dbqueries::source_exists(u.as_str())? {
add_button.set_sensitive(true);
result.hide();
result.set_label("");
} else {
add_button.set_sensitive(false);
result.set_label("Show already exists.");
result.show();
}
Ok(())
}
Err(err) => {
add_button.set_sensitive(false);
if !uri.is_empty() {
result.set_label("Invalid url.");
result.show();
error!("Error: {}", err);
} else {
result.hide();
}
Ok(())
}
}
}
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
fn about_dialog(window: &gtk::Window) {
// Feel free to add yourself if you contribured.
let authors = &[
"Constantin Nickel",
"Gabriele Musco",
"James Wykeham-Martin",
"Jordan Petridis",
"Julian Sparber",
"Rowan Lewis",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.3.1");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
// dialog.set_website_label("Learn more about Hammond");
dialog.set_transient_for(window);
dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors);
dialog.show();
}
+29 -20
View File
@@ -1,13 +1,25 @@
#![cfg_attr(feature = "cargo-clippy",
allow(clone_on_ref_ptr, needless_pass_by_value, useless_format, blacklisted_name,
match_same_arms))]
#![cfg_attr(
feature = "cargo-clippy",
allow(clone_on_ref_ptr, blacklisted_name, match_same_arms, option_map_unit_fn)
)]
#![allow(unknown_lints)]
#![deny(unused_extern_crates, unused)]
// Enable lint group collections
#![warn(nonstandard_style, edition_2018, rust_2018_idioms, bad_style, unused)]
// standalone lints
#![warn(
const_err, improper_ctypes, non_shorthand_field_patterns, no_mangle_generic_items,
overflowing_literals, plugin_as_library, private_no_mangle_fns, private_no_mangle_statics,
unconditional_recursion, unions_with_drop_fields, while_true, missing_debug_implementations,
trivial_casts, trivial_numeric_casts, elided_lifetime_in_paths, missing_copy_implementations
)]
#![deny(warnings)]
extern crate gdk;
extern crate gdk_pixbuf;
extern crate gio;
extern crate glib;
extern crate gstreamer as gst;
extern crate gstreamer_player as gst_player;
extern crate gtk;
#[macro_use]
@@ -24,9 +36,10 @@ extern crate log;
extern crate pretty_assertions;
extern crate chrono;
extern crate crossbeam_channel;
extern crate hammond_data;
extern crate hammond_downloader;
extern crate html2pango;
extern crate html2text;
extern crate humansize;
extern crate loggerv;
extern crate open;
@@ -35,7 +48,6 @@ extern crate regex;
extern crate reqwest;
extern crate send_cell;
extern crate serde_json;
extern crate take_mut;
extern crate url;
use log::Level;
@@ -61,20 +73,16 @@ macro_rules! clone {
);
}
// They do not need to be public
// But it helps when looking at the generated docs.
pub mod views;
pub mod widgets;
pub mod stacks;
mod stacks;
mod widgets;
pub mod headerbar;
pub mod app;
mod app;
mod headerbar;
pub mod settings;
pub mod utils;
pub mod manager;
pub mod static_resource;
pub mod appnotif;
mod manager;
mod settings;
mod static_resource;
mod utils;
use app::App;
@@ -82,16 +90,17 @@ fn main() {
// TODO: make the the logger a cli -vv option
loggerv::init_with_level(Level::Info).expect("Error initializing loggerv.");
gtk::init().expect("Error initializing gtk.");
gst::init().expect("Error initializing gstreamer");
static_resource::init().expect("Something went wrong with the resource file initialization.");
// Add custom style
let provider = gtk::CssProvider::new();
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/Hammond/gtk/style.css");
gtk::StyleContext::add_provider_for_screen(
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
&provider,
600,
);
App::new().run();
App::run();
}
+8 -23
View File
@@ -5,11 +5,8 @@ use rayon;
use hammond_data::dbqueries;
use hammond_downloader::downloader::{get_episode, DownloadProgress};
use app::Action;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::sync::mpsc::Sender;
// use std::sync::atomic::AtomicUsize;
// use std::path::PathBuf;
@@ -78,7 +75,7 @@ lazy_static! {
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
}
pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Error> {
pub fn add(id: i32, directory: String) -> Result<(), Error> {
// Create a new `Progress` struct to keep track of dl progress.
let prog = Arc::new(Mutex::new(Progress::default()));
@@ -88,14 +85,12 @@ pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Err
};
DLPOOL.spawn(move || {
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
let pid = episode.podcast_id();
if let Ok(mut episode) = dbqueries::get_episode_widget_from_rowid(id) {
let id = episode.rowid();
if let Err(err) = get_episode(&mut episode.into(), directory.as_str(), Some(prog)) {
error!("Error while trying to download an episode");
error!("Error: {}", err);
}
get_episode(&mut episode, directory.as_str(), Some(prog))
.map_err(|err| error!("Download Failed: {}", err))
.ok();
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
let foo = m.remove(&id);
@@ -105,13 +100,6 @@ pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Err
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
// }
sender
.send(Action::RefreshEpisodesView)
.expect("Action channel blew up.");
sender
.send(Action::RefreshWidgetIfSame(pid))
.expect("Action channel blew up.");
}
});
@@ -122,17 +110,16 @@ pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Err
mod tests {
use super::*;
use hammond_data::{Episode, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::utils::get_download_folder;
use hammond_data::{Episode, Source};
use hammond_downloader::downloader::get_episode;
use std::{thread, time};
use std::fs;
use std::path::Path;
use std::sync::mpsc::channel;
use std::{thread, time};
#[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
@@ -154,11 +141,9 @@ mod tests {
// Get an episode
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
let (sender, _rx) = channel();
let download_fold = get_download_folder(&pd.title()).unwrap();
let fold2 = download_fold.clone();
add(episode.rowid(), download_fold, sender).unwrap();
add(episode.rowid(), download_fold).unwrap();
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
// Give it soem time to download the file
+49 -3
View File
@@ -1,8 +1,11 @@
use gio;
use gio::SettingsExt;
use gio::{Settings, SettingsExt};
use gtk;
use gtk::GtkWindowExt;
use chrono::prelude::*;
use chrono::Duration;
pub struct WindowGeometry {
left: i32,
top: i32,
@@ -12,7 +15,7 @@ pub struct WindowGeometry {
}
impl WindowGeometry {
pub fn from_window(window: &gtk::Window) -> WindowGeometry {
pub fn from_window(window: &gtk::ApplicationWindow) -> WindowGeometry {
let position = window.get_position();
let size = window.get_size();
let left = position.0;
@@ -46,7 +49,7 @@ impl WindowGeometry {
}
}
pub fn apply(&self, window: &gtk::Window) {
pub fn apply(&self, window: &gtk::ApplicationWindow) {
if self.width > 0 && self.height > 0 {
window.resize(self.width, self.height);
}
@@ -67,6 +70,49 @@ impl WindowGeometry {
}
}
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = i64::from(settings.get_int("refresh-interval-time"));
let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str())
}
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = i64::from(settings.get_int("cleanup-age-time"));
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
}
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period {
"weeks" => Duration::weeks(time),
"days" => Duration::days(time),
"hours" => Duration::hours(time),
"minutes" => Duration::minutes(time),
_ => Duration::seconds(time),
}
}
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test]
// fn test_apply_window_geometry() {
// gtk::init().expect("Error initializing gtk.");
+46 -51
View File
@@ -1,94 +1,89 @@
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use app::Action;
use stacks::EpisodeStack;
use stacks::ShowStack;
use stacks::{HomeStack, ShowStack};
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone)]
pub struct Content {
stack: gtk::Stack,
shows: Arc<ShowStack>,
episodes: Arc<EpisodeStack>,
shows: Rc<RefCell<ShowStack>>,
home: Rc<RefCell<HomeStack>>,
sender: Sender<Action>,
}
impl Content {
pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
pub fn new(sender: &Sender<Action>) -> Result<Rc<Content>, Error> {
let stack = gtk::Stack::new();
let episodes = Arc::new(EpisodeStack::new(sender.clone())?);
let shows = Arc::new(ShowStack::new(sender.clone())?);
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?));
stack.add_titled(&episodes.get_stack(), "episodes", "Episodes");
stack.add_titled(&shows.get_stack(), "shows", "Shows");
stack.add_titled(&home.borrow().get_stack(), "home", "New");
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
Ok(Content {
let con = Content {
stack,
shows,
episodes,
sender,
})
home,
sender: sender.clone(),
};
Ok(Rc::new(con))
}
pub fn update(&self) {
self.update_episode_view();
self.update_shows_view();
self.update_widget()
self.update_home();
self.update_shows();
}
// TODO: Maybe propagate the error?
pub fn update_episode_view(&self) {
if let Err(err) = self.episodes.update() {
error!("Something went wrong while trying to update the episode view.");
error!("Error: {}", err);
pub fn update_home(&self) {
self.home
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update HomeView: {}", err))
.ok();
}
pub fn update_home_if_background(&self) {
if self.stack.get_visible_child_name() != Some("home".into()) {
self.update_home();
}
}
pub fn update_episode_view_if_baground(&self) {
if self.stack.get_visible_child_name() != Some("episodes".into()) {
self.update_episode_view();
}
fn update_shows(&self) {
self.shows
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update ShowsView: {}", err))
.ok();
}
pub fn update_shows_view(&self) {
if let Err(err) = self.shows.update_podcasts() {
error!("Something went wrong while trying to update the ShowsView.");
error!("Error: {}", err);
}
}
pub fn update_widget(&self) {
if let Err(err) = self.shows.update_widget() {
error!("Something went wrong while trying to update the Show Widget.");
error!("Error: {}", err);
}
self.shows
.borrow_mut()
.update()
.map_err(|err| error!("Failed to update ShowsView: {}", err))
.ok();
}
pub fn update_widget_if_same(&self, pid: i32) {
if let Err(err) = self.shows.update_widget_if_same(pid) {
error!("Something went wrong while trying to update the Show Widget.");
error!("Error: {}", err);
}
}
pub fn update_widget_if_visible(&self) {
if self.stack.get_visible_child_name() == Some("shows".to_string())
&& self.shows.get_stack().get_visible_child_name() == Some("widget".to_string())
{
self.update_widget();
}
let pop = self.shows.borrow().populated();
pop.borrow_mut()
.update_widget_if_same(pid)
.map_err(|err| error!("Failed to update ShowsWidget: {}", err))
.ok();
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn get_shows(&self) -> Arc<ShowStack> {
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
self.shows.clone()
}
}
-77
View File
@@ -1,77 +0,0 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use failure::Error;
use views::{EmptyView, EpisodesView};
use app::Action;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct EpisodeStack {
stack: gtk::Stack,
sender: Sender<Action>,
}
impl EpisodeStack {
pub fn new(sender: Sender<Action>) -> Result<EpisodeStack, Error> {
let episodes = EpisodesView::new(sender.clone())?;
let empty = EmptyView::new();
let stack = gtk::Stack::new();
stack.add_named(&episodes.container, "episodes");
stack.add_named(&empty.container, "empty");
if episodes.is_empty() {
stack.set_visible_child_name("empty");
} else {
stack.set_visible_child_name("episodes");
}
Ok(EpisodeStack { stack, sender })
}
// Look into refactoring to a state-machine.
pub fn update(&self) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("episodes")
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
let eps = EpisodesView::new(self.sender.clone())?;
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| eps.set_vadjustment(&x));
self.stack.remove(&old);
self.stack.add_named(&eps.container, "episodes");
if eps.is_empty() {
self.stack.set_visible_child_name("empty");
} else {
self.stack.set_visible_child_name("episodes");
}
old.destroy();
Ok(())
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
}
+117
View File
@@ -0,0 +1,117 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::dbqueries::is_episodes_populated;
use hammond_data::errors::DataError;
use app::Action;
use widgets::{EmptyView, HomeView};
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
enum State {
Home,
Empty,
}
#[derive(Debug, Clone)]
pub struct HomeStack {
empty: EmptyView,
episodes: Rc<HomeView>,
stack: gtk::Stack,
state: State,
sender: Sender<Action>,
}
impl HomeStack {
pub fn new(sender: Sender<Action>) -> Result<HomeStack, Error> {
let episodes = HomeView::new(sender.clone())?;
let empty = EmptyView::new();
let stack = gtk::Stack::new();
let state = State::Empty;
stack.add_named(&episodes.container, "home");
stack.add_named(&empty.container, "empty");
let mut home = HomeStack {
empty,
episodes,
stack,
state,
sender,
};
home.determine_state()?;
Ok(home)
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn update(&mut self) -> Result<(), Error> {
// Copy the vertical scrollbar adjustment from the old view.
self.episodes
.save_alignment()
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
.ok();
self.replace_view()?;
// Determine the actuall state.
self.determine_state().map_err(From::from)
}
fn replace_view(&mut self) -> Result<(), Error> {
// Get the container of the view
let old = &self.episodes.container.clone();
let eps = HomeView::new(self.sender.clone())?;
// Remove the old widget and add the new one
// during this the previous view is removed,
// and the visibile child fallsback to empty view.
self.stack.remove(old);
self.stack.add_named(&eps.container, "home");
// Keep the previous state.
let s = self.state;
// Set the visible child back to the previous one to avoid
// the stack transition animation to show the empty view
self.switch_visible(s, StackTransitionType::None);
// replace view in the struct too
self.episodes = eps;
// This might not be needed
old.destroy();
Ok(())
}
fn switch_visible(&mut self, s: State, animation: StackTransitionType) {
use self::State::*;
match s {
Home => {
self.stack.set_visible_child_full("home", animation);
self.state = Home;
}
Empty => {
self.stack.set_visible_child_full("empty", animation);
self.state = Empty;
}
}
}
fn determine_state(&mut self) -> Result<(), DataError> {
if is_episodes_populated()? {
self.switch_visible(State::Home, StackTransitionType::Crossfade);
} else {
self.switch_visible(State::Empty, StackTransitionType::Crossfade);
};
Ok(())
}
}
+5 -3
View File
@@ -1,7 +1,9 @@
mod content;
mod episode;
mod home;
mod populated;
mod show;
pub use self::content::Content;
pub use self::episode::EpisodeStack;
pub use self::show::ShowStack;
pub use self::home::HomeStack;
pub use self::populated::{PopulatedStack, PopulatedState};
pub use self::show::{ShowStack, ShowState};
+160
View File
@@ -0,0 +1,160 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::dbqueries;
use hammond_data::Show;
use app::Action;
use widgets::{ShowWidget, ShowsView};
use std::rc::Rc;
use std::sync::Arc;
#[derive(Debug, Clone, Copy)]
pub enum PopulatedState {
View,
Widget,
}
#[derive(Debug, Clone)]
pub struct PopulatedStack {
container: gtk::Box,
populated: Rc<ShowsView>,
show: Rc<ShowWidget>,
stack: gtk::Stack,
state: PopulatedState,
sender: Sender<Action>,
}
impl PopulatedStack {
pub fn new(sender: Sender<Action>) -> Result<PopulatedStack, Error> {
let stack = gtk::Stack::new();
let state = PopulatedState::View;
let populated = ShowsView::new(sender.clone())?;
let show = Rc::new(ShowWidget::default());
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
stack.add_named(&populated.container, "shows");
stack.add_named(&show.container, "widget");
container.add(&stack);
container.show_all();
let show = PopulatedStack {
container,
stack,
populated,
show,
state,
sender,
};
Ok(show)
}
pub fn update(&mut self) {
self.update_widget().map_err(|err| format!("{}", err)).ok();
self.update_shows().map_err(|err| format!("{}", err)).ok();
}
pub fn update_shows(&mut self) -> Result<(), Error> {
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.replace_shows()?;
self.switch_visible(s, StackTransitionType::Crossfade);
Ok(())
}
pub fn replace_shows(&mut self) -> Result<(), Error> {
let old = &self.populated.container.clone();
debug!("Name: {:?}", WidgetExt::get_name(old));
self.populated
.save_alignment()
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
.ok();
let pop = ShowsView::new(self.sender.clone())?;
self.populated = pop;
self.stack.remove(old);
self.stack.add_named(&self.populated.container, "shows");
old.destroy();
Ok(())
}
pub fn replace_widget(&mut self, pd: Arc<Show>) -> Result<(), Error> {
let old = self.show.container.clone();
// save the ShowWidget vertical scrollabar alignment
self.show.show_id().map(|id| self.show.save_vadjustment(id));
let new = ShowWidget::new(pd, self.sender.clone());
self.show = new;
self.stack.remove(&old);
self.stack.add_named(&self.show.container, "widget");
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::None);
Ok(())
}
pub fn update_widget(&mut self) -> Result<(), Error> {
let old = self.show.container.clone();
let id = self.show.show_id();
if id.is_none() {
return Ok(());
}
let pd = dbqueries::get_podcast_from_id(id.unwrap_or_default())?;
self.replace_widget(Arc::new(pd))?;
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::Crossfade);
old.destroy();
Ok(())
}
// Only update widget if its show_id is equal to pid.
pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
if self.show.show_id() != Some(pid) {
debug!("Different widget. Early return");
return Ok(());
}
self.update_widget()
}
pub fn container(&self) -> gtk::Box {
self.container.clone()
}
pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
use self::PopulatedState::*;
match state {
View => {
self.stack.set_visible_child_full("shows", animation);
self.state = View;
}
Widget => {
self.stack.set_visible_child_full("widget", animation);
self.state = Widget;
}
}
}
}
+67 -154
View File
@@ -1,181 +1,94 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use views::{EmptyView, ShowsPopulated};
use hammond_data::dbqueries::is_podcasts_populated;
use app::Action;
use widgets::ShowWidget;
use stacks::PopulatedStack;
use utils::get_ignored_shows;
use widgets::EmptyView;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
pub enum ShowState {
Populated,
Empty,
}
#[derive(Debug, Clone)]
pub struct ShowStack {
empty: EmptyView,
populated: Rc<RefCell<PopulatedStack>>,
stack: gtk::Stack,
state: ShowState,
sender: Sender<Action>,
}
impl ShowStack {
pub fn new(sender: Sender<Action>) -> Result<ShowStack, Error> {
pub fn new(sender: Sender<Action>) -> Result<Self, Error> {
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())?));
let empty = EmptyView::new();
let stack = gtk::Stack::new();
let state = ShowState::Empty;
let show = ShowStack {
stack.add_named(&populated.borrow().container(), "populated");
stack.add_named(&empty.container, "empty");
let mut show = ShowStack {
empty,
populated,
stack,
sender: sender.clone(),
state,
sender,
};
let pop = ShowsPopulated::new(sender.clone())?;
let widget = ShowWidget::default();
let empty = EmptyView::new();
show.stack.add_named(&pop.container, "podcasts");
show.stack.add_named(&widget.container, "widget");
show.stack.add_named(&empty.container, "empty");
if pop.is_empty() {
show.stack.set_visible_child_name("empty")
} else {
show.stack.set_visible_child_name("podcasts")
}
show.determine_state()?;
Ok(show)
}
// pub fn update(&self) {
// self.update_widget();
// self.update_podcasts();
// }
pub fn update_podcasts(&self) -> Result<(), Error> {
let vis = self.stack
.get_visible_child_name()
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
let old = self.stack
.get_child_by_name("podcasts")
.ok_or_else(|| format_err!("Faild to get \"podcasts\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
let pop = ShowsPopulated::new(self.sender.clone())?;
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| pop.set_vadjustment(&x));
self.stack.remove(&old);
self.stack.add_named(&pop.container, "podcasts");
if pop.is_empty() {
self.stack.set_visible_child_name("empty");
} else if vis != "empty" {
self.stack.set_visible_child_name(&vis);
} else {
self.stack.set_visible_child_name("podcasts");
}
old.destroy();
Ok(())
}
pub fn replace_widget(&self, pd: Arc<Podcast>) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let new = ShowWidget::new(pd, self.sender.clone());
// Each composite ShowWidget is a gtkBox with the Podcast.id encoded in the
// gtk::Widget name. It's a hack since we can't yet subclass GObject
// easily.
let oldid = WidgetExt::get_name(&old);
let newid = WidgetExt::get_name(&new.container);
debug!("Old widget Name: {:?}\nNew widget Name: {:?}", oldid, newid);
// Only copy the old scrollbar if both widget's represent the same podcast.
if newid == oldid {
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| new.set_vadjustment(&x));
}
self.stack.remove(&old);
self.stack.add_named(&new.container, "widget");
Ok(())
}
pub fn update_widget(&self) -> Result<(), Error> {
let vis = self.stack
.get_visible_child_name()
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
let id = WidgetExt::get_name(&old);
if id == Some("GtkBox".to_string()) || id.is_none() {
return Ok(());
}
let id = id.ok_or_else(|| format_err!("Failed to get widget's name."))?;
let pd = dbqueries::get_podcast_from_id(id.parse::<i32>()?)?;
self.replace_widget(Arc::new(pd))?;
self.stack.set_visible_child_name(&vis);
old.destroy();
Ok(())
}
// Only update widget if it's podcast_id is equal to pid.
pub fn update_widget_if_same(&self, pid: i32) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
let id = WidgetExt::get_name(&old);
if id != Some(pid.to_string()) || id.is_none() {
debug!("Different widget. Early return");
return Ok(());
}
self.update_widget()
}
pub fn switch_podcasts_animated(&self) {
self.stack
.set_visible_child_full("podcasts", gtk::StackTransitionType::SlideRight);
}
pub fn switch_widget_animated(&self) {
self.stack
.set_visible_child_full("widget", gtk::StackTransitionType::SlideLeft)
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
self.populated.clone()
}
pub fn update(&mut self) -> Result<(), Error> {
self.populated.borrow_mut().update();
self.determine_state()
}
fn switch_visible(&mut self, s: ShowState) {
use self::ShowState::*;
match s {
Populated => {
self.stack.set_visible_child_name("populated");
self.state = Populated;
}
Empty => {
self.stack.set_visible_child_name("empty");
self.state = Empty;
}
};
}
fn determine_state(&mut self) -> Result<(), Error> {
use self::ShowState::*;
let ign = get_ignored_shows()?;
debug!("IGNORED SHOWS {:?}", ign);
if is_podcasts_populated(&ign)? {
self.switch_visible(Populated);
} else {
self.switch_visible(Empty);
};
Ok(())
}
}
+215 -119
View File
@@ -1,11 +1,14 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use gdk::FrameClockExt;
use gdk_pixbuf::Pixbuf;
use gio::{Settings, SettingsExt};
use glib;
use gtk;
use gtk::prelude::*;
use gtk::{IsA, Widget};
use chrono::prelude::*;
use crossbeam_channel::{unbounded, Sender};
use failure::Error;
use rayon;
use regex::Regex;
@@ -14,21 +17,129 @@ use send_cell::SendCell;
use serde_json::Value;
// use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries;
use hammond_data::opml;
use hammond_data::pipeline;
use hammond_data::utils::checkup;
use hammond_data::Source;
use hammond_downloader::downloader;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, RwLock};
use std::sync::Arc;
use std::sync::mpsc::*;
use std::sync::{Arc, Mutex, RwLock};
use app::Action;
use chrono::Duration;
use chrono::prelude::*;
/// Lazy evaluates and loads widgets to the parent `container` widget.
///
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
/// will be constructed. An `FnMut` function that returns the desired
/// widget should be passed as the widget `constructor`. You can also specify
/// a `callback` that will be executed when the iteration finish.
///
/// ```no_run
/// # struct Message;
/// # struct MessageWidget(gtk::Label);
///
/// # impl MessageWidget {
/// # fn new(_: Message) -> Self {
/// # MessageWidget(gtk::Label::new("A message"))
/// # }
/// # }
///
/// let messages: Vec<Message> = Vec::new();
/// let list = gtk::ListBox::new();
/// let constructor = |m| MessageWidget::new(m).0;
/// lazy_load(messages, list, constructor, || {});
/// ```
///
/// If you have already constructed the widgets and only want to
/// load them to the parent you can pass a closure that returns it's
/// own argument to the constructor.
///
/// ```no_run
/// # use std::collections::binary_heap::BinaryHeap;
/// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
/// let list = gtk::ListBox::new();
/// lazy_load(widgets, list, |w| w, || {});
/// ```
pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
C: ContainerExt + 'static,
F: FnMut(T::Item) -> W + 'static,
W: IsA<Widget> + WidgetExt,
U: Fn() + 'static,
{
let func = move |x| {
let widget = contructor(x);
container.add(&widget);
widget.show();
};
lazy_load_full(data, func, callback);
}
/// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
/// when the iteration finishes, it executes `finish_callback`.
///
/// This is a more flexible version of `lazy_load` with less constrains.
/// If you just want to lazy add `widgets` to a `container` check if
/// `lazy_load` fits your needs first.
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
F: FnMut(T::Item) + 'static,
U: Fn() + 'static,
{
let mut data = data.into_iter();
gtk::idle_add(move || {
data.next()
.map(|x| func(x))
.map(|_| glib::Continue(true))
.unwrap_or_else(|| {
finish_callback();
glib::Continue(false)
})
});
}
// Kudos to Julian Sparber
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
pub fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::Adjustment) {
if let Some(adj) = view.get_vadjustment() {
if let Some(clock) = view.get_frame_clock() {
let duration = 200;
let start = adj.get_value();
let end = target.get_value();
let start_time = clock.get_frame_time();
let end_time = start_time + 1000 * duration;
view.add_tick_callback(move |_, clock| {
let now = clock.get_frame_time();
// FIXME: `adj.get_value != end` is a float comparison...
if now < end_time && adj.get_value().abs() != end.abs() {
let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
t = ease_out_cubic(t);
adj.set_value(start + t * (end - start));
glib::Continue(true)
} else {
adj.set_value(end);
glib::Continue(false)
}
});
}
}
}
// From clutter-easing.c, based on Robert Penner's
// infamous easing equations, MIT license.
fn ease_out_cubic(t: f64) -> f64 {
let p = t - 1f64;
p * p * p + 1f64
}
lazy_static! {
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
@@ -56,78 +167,47 @@ pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
}
pub fn cleanup(cleanup_date: DateTime<Utc>) {
if let Err(err) = checkup(cleanup_date) {
error!("Check up failed: {}", err);
}
checkup(cleanup_date)
.map_err(|err| error!("Check up failed: {}", err))
.ok();
}
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
pub fn refresh<S>(source: Option<S>, sender: Sender<Action>)
where
S: IntoIterator<Item = Source> + Send + 'static,
{
refresh_feed(source, sender)
.map_err(|err| error!("Failed to update feeds: {}", err))
.ok();
}
/// Update the rss feed(s) originating from `source`.
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
/// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
sender.send(Action::HeaderBarShowUpdateIndicator)?;
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
where
S: IntoIterator<Item = Source> + Send + 'static,
{
sender.send(Action::HeaderBarShowUpdateIndicator);
rayon::spawn(move || {
let mut sources = source.unwrap_or_else(|| {
dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
});
if let Some(s) = source {
// Refresh only specified feeds
pipeline::run(s, false)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Error While trying to update the database."))
.ok();
} else {
// Refresh all the feeds
dbqueries::get_sources()
.map(|s| s.into_iter())
.and_then(|s| pipeline::run(s, false))
.map_err(|err| error!("Error: {}", err))
.ok();
};
// Work around to improve the feed addition experience.
// Many times links to rss feeds are just redirects(usually to an https
// version). Sadly I haven't figured yet a nice way to follow up links
// redirects without getting to lifetime hell with futures and hyper.
// So the requested refresh is only of 1 feed, and the feed fails to be indexed,
// (as a 301 redict would update the source entry and exit), another refresh is
// run. For more see hammond_data/src/models/source.rs `fn
// request_constructor`. also ping me on irc if or open an issue if you
// want to tackle it.
if sources.len() == 1 {
let source = sources.remove(0);
let id = source.id();
if let Err(err) = pipeline::index_single_source(source, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
if let Ok(source) = dbqueries::get_source_from_id(id) {
if let Err(err) = pipeline::index_single_source(source, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
}
}
// This is what would normally run
} else if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
sender
.send(Action::HeaderBarHideUpdateIndicator)
.expect("Action channel blew up.");
sender
.send(Action::RefreshAllViews)
.expect("Action channel blew up.");
sender.send(Action::HeaderBarHideUpdateIndicator);
sender.send(Action::RefreshAllViews);
});
Ok(())
}
@@ -146,19 +226,15 @@ 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 set_image_from_path(
image: &gtk::Image,
pd: Arc<PodcastCoverQuery>,
size: u32,
) -> Result<(), Error> {
pub fn set_image_from_path(image: &gtk::Image, show_id: i32, size: u32) -> Result<(), Error> {
// Check if there's an active download about this show cover.
// If there is, a callback will be set so this function will be called again.
// If the download succedes, there should be a quick return from the pixbuf cache_image
// If it fails another download will be scheduled.
if let Ok(guard) = COVER_DL_REGISTRY.read() {
if guard.contains(&pd.id()) {
let callback = clone!(image, pd => move || {
let _ = set_image_from_path(&image, pd.clone(), size);
if guard.contains(&show_id) {
let callback = clone!(image => move || {
let _ = set_image_from_path(&image, show_id, size);
glib::Continue(false)
});
gtk::timeout_add(250, callback);
@@ -169,7 +245,7 @@ pub fn set_image_from_path(
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)) {
if let Some(guard) = hashmap.get(&(show_id, size)) {
guard
.lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err))
@@ -184,28 +260,25 @@ pub fn set_image_from_path(
}
}
let (sender, receiver) = channel();
let pd_ = pd.clone();
let (sender, receiver) = unbounded();
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());
guard.insert(show_id);
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(show_id) {
sender.send(downloader::cache_image(&pd));
}
guard.remove(&show_id);
}
});
let image = image.clone();
let s = size as i32;
gtk::timeout_add(25, move || {
if let Ok(path) = receiver.try_recv() {
if let Some(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())));
hashmap.insert((show_id, size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px);
}
}
@@ -218,14 +291,12 @@ pub fn set_image_from_path(
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();
@@ -237,7 +308,6 @@ fn itunes_id_from_url(url: &str) -> Option<u32> {
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()?;
@@ -247,14 +317,58 @@ fn lookup_id(id: u32) -> Result<String, Error> {
.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),
}
pub fn on_import_clicked(window: &gtk::ApplicationWindow, sender: &Sender<Action>) {
use glib::translate::ToGlib;
use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType};
// let dialog = FileChooserDialog::new(title, Some(&window), FileChooserAction::Open);
// TODO: It might be better to use a FileChooserNative widget.
// Create the FileChooser Dialog
let dialog = FileChooserNative::new(
Some("Select the file from which to you want to Import Shows."),
Some(window),
FileChooserAction::Open,
Some("_Import"),
None,
);
// Do not show hidden(.thing) files
dialog.set_show_hidden(false);
// Set a filter to show only xml files
let filter = FileFilter::new();
FileFilterExt::set_name(&filter, Some("OPML file"));
filter.add_mime_type("application/xml");
filter.add_mime_type("text/xml");
dialog.add_filter(&filter);
dialog.connect_response(clone!(sender => move |dialog, resp| {
debug!("Dialong Response {}", resp);
if resp == ResponseType::Accept.to_glib() {
// TODO: Show an in-app notifictaion if the file can not be accessed
if let Some(filename) = dialog.get_filename() {
debug!("File selected: {:?}", filename);
rayon::spawn(clone!(sender => move || {
// Parse the file and import the feeds
if let Ok(sources) = opml::import_from_file(filename) {
// Refresh the succesfully parsed feeds to index them
refresh(Some(sources), sender)
} else {
let text = String::from("Failed to parse the Imported file");
sender.send(Action::ErrorNotification(text));
}
}))
} else {
let text = String::from("Selected File could not be accessed.");
sender.send(Action::ErrorNotification(text));
}
}
dialog.destroy();
}));
dialog.run();
}
#[cfg(test)]
@@ -263,29 +377,11 @@ mod tests {
// use hammond_data::Source;
// use hammond_data::dbqueries;
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
// #[ignore]
// Disabled till https://gitlab.gnome.org/alatiera/Hammond/issues/56
// Disabled till https://gitlab.gnome.org/World/hammond/issues/56
// fn test_set_image_from_path() {
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
-7
View File
@@ -1,7 +0,0 @@
mod shows;
mod episodes;
mod empty;
pub use self::empty::EmptyView;
pub use self::episodes::EpisodesView;
pub use self::shows::ShowsPopulated;
-138
View File
@@ -1,138 +0,0 @@
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::{Podcast, PodcastCoverQuery};
use hammond_data::dbqueries;
use app::Action;
use utils::{get_ignored_shows, set_image_from_path};
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct ShowsPopulated {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsPopulated {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsPopulated {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsPopulated {
pub fn new(sender: Sender<Action>) -> Result<ShowsPopulated, Error> {
let pop = ShowsPopulated::default();
pop.init(sender)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) -> Result<(), Error> {
self.flowbox.connect_child_activated(move |_, child| {
if let Err(err) = on_child_activate(child, sender.clone()) {
error!(
"Something went wrong during flowbox child activation: {}.",
err
)
};
});
// Populate the flowbox with the Podcasts.
self.populate_flowbox()
}
fn populate_flowbox(&self) -> Result<(), Error> {
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
podcasts.into_iter().for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
Ok(())
}
pub fn is_empty(&self) -> bool {
self.flowbox.get_children().is_empty()
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn on_child_activate(child: &gtk::FlowBoxChild, sender: Sender<Action>) -> Result<(), Error> {
use gtk::WidgetExt;
// This is such an ugly hack...
let id = WidgetExt::get_name(child)
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.parse::<i32>()?;
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
sender.send(Action::HeaderBarShowTile(pd.title().into()))?;
sender.send(Action::ReplaceWidget(pd))?;
sender.send(Action::ShowWidgetAnimated)?;
Ok(())
}
#[derive(Debug)]
struct ShowsChild {
container: gtk::Box,
cover: gtk::Image,
child: gtk::FlowBoxChild,
}
impl Default for ShowsChild {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_child.ui");
let container: gtk::Box = builder.get_object("fb_child").unwrap();
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
let child = gtk::FlowBoxChild::new();
child.add(&container);
ShowsChild {
container,
cover,
child,
}
}
}
impl ShowsChild {
pub fn new(pd: Podcast) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: Podcast) {
self.container.set_tooltip_text(pd.title());
WidgetExt::set_name(&self.child, &pd.id().to_string());
let pd = Arc::new(pd.into());
if let Err(err) = self.set_cover(pd) {
error!("Failed to set a cover: {}", err)
}
}
fn set_cover(&self, pd: Arc<PodcastCoverQuery>) -> Result<(), Error> {
set_image_from_path(&self.cover, pd, 256)
}
}
+42
View File
@@ -0,0 +1,42 @@
use gtk;
use gtk::prelude::*;
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
/// Given a `window` create and attach an `gtk::AboutDialog` to it.
pub fn about_dialog(window: &gtk::ApplicationWindow) {
// Feel free to add yourself if you contribured.
let authors = &[
"Constantin Nickel",
"Gabriele Musco",
"James Wykeham-Martin",
"Jordan Petridis",
"Julian Sparber",
"Rowan Lewis",
"Zander Brown",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
// dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_logo_icon_name("multimedia-player");
dialog.set_comments("Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.4.0");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
// dialog.set_website_label("Learn more about Hammond");
dialog.set_transient_for(window);
dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors);
dialog.connect_response(|dlg, _| dlg.destroy());
dialog.show();
}
@@ -2,11 +2,14 @@ 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, Copy)]
pub enum UndoState {
Shown,
Hidden,
}
#[derive(Debug, Clone)]
pub struct InAppNotification {
@@ -18,7 +21,7 @@ pub struct InAppNotification {
impl Default for InAppNotification {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/inapp_notif.ui");
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();
@@ -35,12 +38,7 @@ impl Default for InAppNotification {
}
impl InAppNotification {
pub fn new<F, U>(
text: String,
mut callback: F,
undo_callback: U,
sender: Sender<Action>,
) -> Self
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self
where
F: FnMut() -> glib::Continue + 'static,
U: Fn() + 'static,
@@ -67,10 +65,6 @@ impl InAppNotification {
// 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
@@ -79,6 +73,11 @@ impl InAppNotification {
revealer.set_reveal_child(false);
});
match show_undo {
UndoState::Shown => (),
UndoState::Hidden => notif.undo.hide(),
}
notif
}
@@ -7,7 +7,7 @@ pub struct EmptyView {
impl Default for EmptyView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_view.ui");
let view: gtk::Box = builder.get_object("empty_view").unwrap();
EmptyView { container: view }
+405 -224
View File
@@ -2,240 +2,458 @@ use glib;
use gtk;
use gtk::prelude::*;
use chrono;
use chrono::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use humansize::FileSize;
use humansize::{file_size_opts as size_opts, FileSize};
#[allow(unused_imports)]
use open;
use take_mut;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries;
use hammond_data::utils::get_download_folder;
use hammond_data::EpisodeWidgetModel;
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;
use std::sync::{Arc, Mutex, TryLockError};
#[derive(Debug)]
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,
})
};
}
#[derive(Clone, Debug)]
pub struct EpisodeWidget {
pub container: gtk::Box,
date: DateMachine,
duration: DurationMachine,
title: Rc<RefCell<TitleMachine>>,
media: Arc<Mutex<MediaMachine>>,
info: InfoLabels,
buttons: Buttons,
progressbar: gtk::ProgressBar,
}
#[derive(Clone, Debug)]
struct InfoLabels {
container: gtk::Box,
title: gtk::Label,
date: gtk::Label,
separator1: gtk::Label,
duration: gtk::Label,
separator2: gtk::Label,
local_size: gtk::Label,
size_separator: gtk::Label,
total_size: gtk::Label,
}
#[derive(Clone, Debug)]
struct Buttons {
container: gtk::ButtonBox,
play: gtk::Button,
download: gtk::Button,
cancel: gtk::Button,
}
impl InfoLabels {
fn init(&self, episode: &EpisodeWidgetModel) {
// Set the title label state.
self.set_title(episode);
// Set the date label.
self.set_date(episode.epoch());
// Set the duaration label.
self.set_duration(episode.duration());
// Set the total_size label.
self.set_size(episode.length())
}
fn set_title(&self, episode: &EpisodeWidgetModel) {
self.title.set_text(episode.title());
if episode.played().is_some() {
self.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
} else {
self.title
.get_style_context()
.map(|c| c.remove_class("dim-label"));
}
}
// Set the date label of the episode widget.
fn set_date(&self, epoch: i32) {
lazy_static! {
static ref NOW: DateTime<Utc> = Utc::now();
};
let ts = Utc.timestamp(i64::from(epoch), 0);
// If the episode is from a different year, print year as well
if NOW.year() != ts.year() {
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
// Else omit the year from the label
} else {
self.date.set_text(ts.format("%e %b").to_string().trim());
}
}
// Set the duration label of the episode widget.
fn set_duration(&self, seconds: Option<i32>) {
// If lenght is provided
if let Some(s) = seconds {
// Convert seconds to minutes
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
// If the lenght is 1 or more minutes
if minutes != 0 {
// Set the label and show them.
self.duration.set_text(&format!("{} min", minutes));
self.duration.show();
self.separator1.show();
return;
}
}
// Else hide the labels
self.separator1.hide();
self.duration.hide();
}
// Set the size label of the episode widget.
fn set_size(&self, bytes: Option<i32>) {
// Convert the bytes to a String label
let size = || -> Option<String> {
let s = bytes?;
if s == 0 {
return None;
}
s.file_size(SIZE_OPTS.clone()).ok()
};
if let Some(s) = size() {
self.total_size.set_text(&s);
self.total_size.show();
self.separator2.show();
} else {
self.total_size.hide();
self.separator2.hide();
}
}
}
impl Default for EpisodeWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episode_widget.ui");
let container: gtk::Box = builder.get_object("episode_container").unwrap();
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
let container = builder.get_object("episode_container").unwrap();
let progressbar = builder.get_object("progress_bar").unwrap();
let download: gtk::Button = builder.get_object("download_button").unwrap();
let play: gtk::Button = builder.get_object("play_button").unwrap();
let cancel: gtk::Button = builder.get_object("cancel_button").unwrap();
let buttons_container = builder.get_object("button_box").unwrap();
let download = builder.get_object("download_button").unwrap();
let play = builder.get_object("play_button").unwrap();
let cancel = builder.get_object("cancel_button").unwrap();
let title: gtk::Label = builder.get_object("title_label").unwrap();
let date: gtk::Label = builder.get_object("date_label").unwrap();
let duration: gtk::Label = builder.get_object("duration_label").unwrap();
let local_size: gtk::Label = builder.get_object("local_size").unwrap();
let total_size: gtk::Label = builder.get_object("total_size").unwrap();
let info_container = builder.get_object("info_container").unwrap();
let title = builder.get_object("title_label").unwrap();
let date = builder.get_object("date_label").unwrap();
let duration = builder.get_object("duration_label").unwrap();
let local_size = builder.get_object("local_size").unwrap();
let total_size = builder.get_object("total_size").unwrap();
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
let separator1 = builder.get_object("separator1").unwrap();
let separator2 = builder.get_object("separator2").unwrap();
let date_machine = DateMachine::new(date, 0);
let dur_machine = DurationMachine::new(duration, separator1, None);
let title_machine = Rc::new(RefCell::new(TitleMachine::new(title, false)));
let media = MediaMachine::new(
play,
download,
progress,
cancel,
total_size,
local_size,
separator2,
prog_separator,
);
let media_machine = Arc::new(Mutex::new(media));
let size_separator = builder.get_object("prog_separator").unwrap();
EpisodeWidget {
info: InfoLabels {
container: info_container,
title,
date,
separator1,
duration,
separator2,
local_size,
total_size,
size_separator,
},
buttons: Buttons {
container: buttons_container,
play,
download,
cancel,
},
progressbar,
container,
title: title_machine,
duration: dur_machine,
date: date_machine,
media: media_machine,
}
}
}
impl EpisodeWidget {
pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let mut widget = EpisodeWidget::default();
widget.init(episode, sender);
pub fn new(episode: &EpisodeWidgetModel, sender: &Sender<Action>) -> Rc<Self> {
let widget = Rc::new(Self::default());
widget.info.init(episode);
Self::determine_buttons_state(&widget, episode, sender)
.map_err(|err| error!("Error: {}", err))
.ok();
widget
}
fn init(&mut self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
// fn init(widget: Rc<Self>, sender: &Sender<Action>) {}
// Set the date label.
self.set_date(episode.epoch());
// InProgress State:
// * Show ProgressBar and Cancel Button.
// * Show `total_size`, `local_size` labels and `size_separator`.
// * Hide Download and Play Buttons
fn state_prog(&self) {
self.progressbar.show();
self.buttons.cancel.show();
// Set the title label state.
self.set_title(&episode);
self.info.total_size.show();
self.info.local_size.show();
self.info.size_separator.show();
// Set the duaration label.
self.set_duration(episode.duration());
// Determine what the state of the media widgets should be.
if let Err(err) = self.determine_media_state(&episode) {
error!("Something went wrong determining the Media State.");
error!("Error: {}", err);
}
let episode = Arc::new(Mutex::new(episode));
self.connect_buttons(episode, sender);
self.buttons.play.hide();
self.buttons.download.hide();
}
fn connect_buttons(&self, episode: Arc<Mutex<EpisodeWidgetQuery>>, sender: Sender<Action>) {
let title = self.title.clone();
if let Ok(media) = self.media.lock() {
media.play_connect_clicked(clone!(episode, sender => move |_| {
if let Ok(mut ep) = episode.lock() {
if let Err(err) = on_play_bttn_clicked(&mut ep, title.clone(), sender.clone()){
error!("Error: {}", err);
};
}
}));
// Playable State:
// * Hide ProgressBar and Cancel, Download Buttons.
// * Hide `local_size` labels and `size_separator`.
// * Show Play Button and `total_size` label
fn state_playable(&self) {
self.progressbar.hide();
self.buttons.cancel.hide();
self.buttons.download.hide();
self.info.local_size.hide();
self.info.size_separator.hide();
media.download_connect_clicked(clone!(episode, sender => move |dl| {
dl.set_sensitive(false);
if let Ok(ep) = episode.lock() {
if let Err(err) = on_download_clicked(&ep, sender.clone()) {
error!("Download failed to start.");
error!("Error: {}", err);
} else {
info!("Donwload started succesfully.");
}
}
}));
}
self.info.total_size.show();
self.buttons.play.show();
}
/// Determine the title state.
fn set_title(&mut self, episode: &EpisodeWidgetQuery) {
let mut machine = self.title.borrow_mut();
machine.set_title(episode.title());
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
// ToDownload State:
// * Hide ProgressBar and Cancel, Play Buttons.
// * Hide `local_size` labels and `size_separator`.
// * Show Download Button
// * Determine `total_size` label state (Comes from `episode.lenght`).
fn state_download(&self) {
self.progressbar.hide();
self.buttons.cancel.hide();
self.buttons.play.hide();
self.info.local_size.hide();
self.info.size_separator.hide();
self.buttons.download.show();
}
/// Set the date label depending on the current time.
fn set_date(&mut self, epoch: i32) {
let machine = &mut self.date;
take_mut::take(machine, |date| date.determine_state(i64::from(epoch)));
fn update_progress(&self, local_size: &str, fraction: f64) {
self.info.local_size.set_text(local_size);
self.progressbar.set_fraction(fraction);
}
/// Set the duration label.
fn set_duration(&mut self, seconds: Option<i32>) {
let machine = &mut self.duration;
take_mut::take(machine, |duration| duration.determine_state(seconds));
}
/// Change the state of the `EpisodeWidget`.
///
/// Function Flowchart:
///
/// ------------------- --------------
/// | Is the Episode | YES | State: |
/// | currently being | ----> | InProgress |
/// | downloaded? | | |
/// ------------------- --------------
/// |
/// | NO
/// |
/// \_/
/// ------------------- --------------
/// | is the episode | YES | State: |
/// | downloaded | ----> | Playable |
/// | already? | | |
/// ------------------- --------------
/// |
/// | NO
/// |
/// \_/
/// -------------------
/// | State: |
/// | ToDownload |
/// -------------------
fn determine_buttons_state(
widget: &Rc<Self>,
episode: &EpisodeWidgetModel,
sender: &Sender<Action>,
) -> Result<(), Error> {
// Reset the buttons state no matter the glade file.
// This is just to make it easier to port to relm in the future.
widget.buttons.cancel.hide();
widget.buttons.play.hide();
widget.buttons.download.hide();
fn determine_media_state(&self, episode: &EpisodeWidgetQuery) -> Result<(), Error> {
let id = WidgetExt::get_name(&self.container)
.ok_or_else(|| format_err!("Failed to get widget Name"))?
.parse::<i32>()?;
let active_dl = || -> Result<Option<_>, Error> {
// Check if the episode is being downloaded
let id = episode.rowid();
let active_dl = move || -> Result<Option<_>, Error> {
let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
Ok(m.get(&id).cloned())
}()?;
};
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(),
)
});
// State: InProggress
if let Some(prog) = active_dl()? {
// set a callback that will update the state when the download finishes
let callback = clone!(widget, sender => move || {
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() {
if !guard.contains_key(&id) {
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
Self::determine_buttons_state(&widget, &ep, &sender)
.map_err(|err| error!("Error: {}", err))
.ok();
// 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);
return glib::Continue(false)
}
}
}
// Setup a callback that will update the progress bar.
update_progressbar_callback(prog.clone(), self.media.clone(), id);
glib::Continue(true)
});
gtk::timeout_add(250, callback);
// Wire the cancel button
widget
.buttons
.cancel
.connect_clicked(clone!(prog, widget, sender => move |_| {
// Cancel the download
if let Ok(mut m) = prog.lock() {
m.cancel();
}
// Cancel is not instant so we have to wait a bit
timeout_add(50, clone!(widget, sender => move || {
if let Ok(thing) = active_dl() {
if thing.is_none() {
// Recalculate the widget state
dbqueries::get_episode_widget_from_rowid(id)
.map_err(From::from)
.and_then(|ep| Self::determine_buttons_state(&widget, &ep, &sender))
.map_err(|err| error!("Error: {}", err))
.ok();
return glib::Continue(false)
}
}
glib::Continue(true)
}));
}));
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), self.media.clone());
update_total_size_callback(&widget, &prog);
// Setup a callback that will update the progress bar.
update_progressbar_callback(&widget, &prog, id);
// Change the widget layout/state
widget.state_prog();
return Ok(());
}
// State: Playable
if episode.local_uri().is_some() {
// Change the widget layout/state
widget.state_playable();
// Wire the play button
widget
.buttons
.play
.connect_clicked(clone!(widget, sender => move |_| {
if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_play_bttn_clicked(&widget, &mut ep, &sender)
.map_err(|err| error!("Error: {}", err))
.ok();
}
}));
return Ok(());
}
// State: ToDownload
// Wire the download button
widget
.buttons
.download
.connect_clicked(clone!(widget, sender => move |dl| {
// Make the button insensitive so it won't be pressed twice
dl.set_sensitive(false);
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_download_clicked(&ep, &sender)
.and_then(|_| {
info!("Donwload started succesfully.");
Self::determine_buttons_state(&widget, &ep, &sender)
})
.map_err(|err| error!("Error: {}", err))
.ok();
}
// Restore sensitivity after operations above complete
dl.set_sensitive(true);
}));
// Change the widget state into `ToDownload`
widget.state_download();
Ok(())
}
}
#[inline]
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
fn on_download_clicked(ep: &EpisodeWidgetModel, sender: &Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.show_id())?;
let download_fold = get_download_folder(&pd.title())?;
// Start a new download.
manager::add(ep.rowid(), download_fold, sender.clone())?;
manager::add(ep.rowid(), download_fold)?;
// Update Views
sender.send(Action::RefreshEpisodesView)?;
sender.send(Action::RefreshWidgetIfVis)?;
sender.send(Action::RefreshEpisodesViewBGR);
Ok(())
}
#[inline]
fn on_play_bttn_clicked(
episode: &mut EpisodeWidgetQuery,
title: Rc<RefCell<TitleMachine>>,
sender: Sender<Action>,
widget: &Rc<EpisodeWidget>,
episode: &mut EpisodeWidgetModel,
sender: &Sender<Action>,
) -> Result<(), Error> {
open_uri(episode.rowid())?;
// Mark played
episode.set_played_now()?;
// Grey out the title
widget.info.set_title(&episode);
let mut machine = title.try_borrow_mut()?;
take_mut::take(machine.deref_mut(), |title| {
title.determine_state(episode.played().is_some())
});
sender.send(Action::RefreshEpisodesViewBGR)?;
Ok(())
}
fn open_uri(rowid: i32) -> Result<(), Error> {
let uri = dbqueries::get_episode_local_uri_from_id(rowid)?
.ok_or_else(|| format_err!("Expected Some found None."))?;
if Path::new(&uri).exists() {
info!("Opening {}", uri);
open::that(&uri)?;
} else {
bail!("File \"{}\" does not exist.", uri);
}
// Play the episode
sender.send(Action::InitEpisode(episode.rowid()));
// Refresh background views to match the normal/greyout title state
sender.send(Action::RefreshEpisodesViewBGR);
Ok(())
}
@@ -243,30 +461,27 @@ fn open_uri(rowid: i32) -> Result<(), Error> {
#[inline]
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
fn update_progressbar_callback(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32,
) {
timeout_add(
400,
clone!(prog, media => move || {
progress_bar_helper(prog.clone(), media.clone(), episode_rowid)
.unwrap_or(glib::Continue(false))
}),
);
let callback = clone!(widget, prog => move || {
progress_bar_helper(&widget, &prog, episode_rowid)
.unwrap_or(glib::Continue(false))
});
timeout_add(100, callback);
}
#[inline]
#[allow(if_same_then_else)]
fn progress_bar_helper(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32,
) -> Result<glib::Continue, Error> {
let (fraction, downloaded) = {
let m = prog.lock()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
(m.get_fraction(), m.get_downloaded())
let (fraction, downloaded) = match prog.try_lock() {
Ok(guard) => (guard.get_fraction(), guard.get_downloaded()),
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
};
// I hate floating points.
@@ -277,19 +492,16 @@ fn progress_bar_helper(
.file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))?;
let mut m = media.lock().unwrap();
m.update_progress(&size, fraction);
widget.update_progress(&size, fraction);
}
// info!("Fraction: {}", progress_bar.get_fraction());
// info!("Fraction: {}", fraction);
// Check if the download is still active
let active = {
let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
m.contains_key(&episode_rowid)
let active = match manager::ACTIVE_DOWNLOADS.read() {
Ok(guard) => guard.contains_key(&episode_rowid),
Err(_) => return Err(format_err!("Failed to get a lock on the mutex.")),
};
if (fraction >= 1.0) && (!fraction.is_nan()) {
@@ -305,38 +517,28 @@ fn progress_bar_helper(
// with the http ContentLength header number rather than
// relying to the RSS feed.
#[inline]
fn update_total_size_callback(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
) {
timeout_add(
500,
clone!(prog, media => move || {
total_size_helper(prog.clone(), media.clone()).unwrap_or(glib::Continue(true))
}),
);
fn update_total_size_callback(widget: &Rc<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) {
let callback = clone!(prog, widget => move || {
total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true))
});
timeout_add(100, callback);
}
#[inline]
fn total_size_helper(
prog: Arc<Mutex<manager::Progress>>,
media: Arc<Mutex<MediaMachine>>,
widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>,
) -> Result<glib::Continue, Error> {
// Get the total_bytes.
let total_bytes = {
let m = prog.lock()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
m.get_total_size()
let total_bytes = match prog.try_lock() {
Ok(guard) => guard.get_total_size(),
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")),
};
debug!("Total Size: {}", total_bytes);
if total_bytes != 0 {
// Update the total_size label
if let Ok(mut m) = media.lock() {
take_mut::take(m.deref_mut(), |machine| {
machine.set_size(Some(total_bytes as i32))
});
}
widget.info.set_size(Some(total_bytes as i32));
// Do not call again the callback
Ok(glib::Continue(false))
@@ -349,24 +551,3 @@ fn total_size_helper(
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
// }
pub fn episodes_listbox(pd: &Podcast, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
let episodes = dbqueries::get_pd_episodeswidgets(pd)?;
let list = gtk::ListBox::new();
list.set_visible(true);
list.set_selection_mode(gtk::SelectionMode::None);
if episodes.is_empty() {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
list.add(&container);
return Ok(list);
}
episodes.into_iter().for_each(|ep| {
let widget = EpisodeWidget::new(ep, sender.clone());
list.add(&widget.container);
});
Ok(list)
}
-917
View File
@@ -1,917 +0,0 @@
// TODO: Things that should be done.
//
// * Wherever there's a function that take 2 or more arguments of the same type,
// eg: fn new(total_size: gtk::Label, local_size: gtk::Label ..)
// Wrap the types into Struct-tuples and imple deref so it won't be possible to pass
// the wrong argument to the wrong position.
use chrono;
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use humansize::{file_size_opts as size_opts, FileSize};
use std::sync::{Arc, Mutex};
use manager::Progress as OtherProgress;
lazy_static! {
pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
Arc::new(size_opts::FileSizeOpts {
divider: size_opts::Kilo::Binary,
units: size_opts::Kilo::Decimal,
decimal_places: 0,
decimal_zeroes: 0,
fixed_at: size_opts::FixedAt::No,
long_units: false,
space: true,
suffix: "",
allow_negative: false,
})
};
static ref NOW: DateTime<Utc> = Utc::now();
}
#[derive(Debug, Clone)]
pub struct UnInitialized;
#[derive(Debug, Clone)]
pub struct Shown;
#[derive(Debug, Clone)]
pub struct Hidden;
pub trait Visibility {}
impl Visibility for Shown {}
impl Visibility for Hidden {}
#[derive(Debug, Clone)]
pub struct Normal;
#[derive(Debug, Clone)]
pub struct GreyedOut;
#[derive(Debug, Clone)]
pub struct Title<S> {
title: gtk::Label,
state: S,
}
impl<S> Title<S> {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn set_title(&mut self, s: &str) {
self.title.set_text(s);
}
}
impl Title<Normal> {
fn new(title: gtk::Label) -> Self {
Title {
title,
state: Normal {},
}
}
}
impl From<Title<Normal>> for Title<GreyedOut> {
fn from(f: Title<Normal>) -> Self {
f.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
Title {
title: f.title,
state: GreyedOut {},
}
}
}
impl From<Title<GreyedOut>> for Title<Normal> {
fn from(f: Title<GreyedOut>) -> Self {
f.title
.get_style_context()
.map(|c| c.remove_class("dim-label"));
Title {
title: f.title,
state: Normal {},
}
}
}
#[derive(Debug, Clone)]
pub enum TitleMachine {
Normal(Title<Normal>),
GreyedOut(Title<GreyedOut>),
}
impl TitleMachine {
pub fn new(label: gtk::Label, is_played: bool) -> Self {
let m = TitleMachine::Normal(Title::<Normal>::new(label));
m.determine_state(is_played)
}
pub fn determine_state(self, is_played: bool) -> Self {
use self::TitleMachine::*;
match (self, is_played) {
(title @ Normal(_), false) => title,
(title @ GreyedOut(_), true) => title,
(Normal(val), true) => GreyedOut(val.into()),
(GreyedOut(val), false) => Normal(val.into()),
}
}
pub fn set_title(&mut self, s: &str) {
use self::TitleMachine::*;
match *self {
Normal(ref mut val) => val.set_title(s),
GreyedOut(ref mut val) => val.set_title(s),
}
}
}
#[derive(Debug, Clone)]
pub struct Usual;
#[derive(Debug, Clone)]
pub struct YearShown;
#[derive(Debug, Clone)]
pub struct Date<S> {
date: gtk::Label,
epoch: i64,
state: S,
}
impl<S> Date<S> {
fn into_usual(self, epoch: i64) -> Date<Usual> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: Usual {},
}
}
fn into_year_shown(self, epoch: i64) -> Date<YearShown> {
let ts = Utc.timestamp(epoch, 0);
self.date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date: self.date,
epoch: self.epoch,
state: YearShown {},
}
}
}
impl Date<UnInitialized> {
fn new(date: gtk::Label, epoch: i64) -> Self {
let ts = Utc.timestamp(epoch, 0);
date.set_text(ts.format("%e %b %Y").to_string().trim());
Date {
date,
epoch,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub enum DateMachine {
UnInitialized(Date<UnInitialized>),
Usual(Date<Usual>),
WithYear(Date<YearShown>),
}
impl DateMachine {
pub fn new(label: gtk::Label, epoch: i64) -> Self {
let m = DateMachine::UnInitialized(Date::<UnInitialized>::new(label, epoch));
m.determine_state(epoch)
}
pub fn determine_state(self, epoch: i64) -> Self {
use self::DateMachine::*;
let ts = Utc.timestamp(epoch, 0);
let is_old = !(NOW.year() == ts.year());
match (self, is_old) {
// Into Usual
(Usual(val), false) => Usual(val.into_usual(epoch)),
(WithYear(val), false) => Usual(val.into_usual(epoch)),
(UnInitialized(val), false) => Usual(val.into_usual(epoch)),
// Into Year Shown
(Usual(val), true) => WithYear(val.into_year_shown(epoch)),
(WithYear(val), true) => WithYear(val.into_year_shown(epoch)),
(UnInitialized(val), true) => WithYear(val.into_year_shown(epoch)),
}
}
}
#[derive(Debug, Clone)]
pub struct Duration<S: Visibility> {
// TODO: make duration and separator diff types
duration: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S: Visibility> Duration<S> {
// This needs a better name.
// TODO: make me mut
fn set_duration(&self, minutes: i64) {
self.duration.set_text(&format!("{} min", minutes));
}
}
impl Duration<Hidden> {
fn new(duration: gtk::Label, separator: gtk::Label) -> Self {
duration.hide();
separator.hide();
Duration {
duration,
separator,
state: Hidden {},
}
}
}
impl From<Duration<Hidden>> for Duration<Shown> {
fn from(f: Duration<Hidden>) -> Self {
f.duration.show();
f.separator.show();
Duration {
duration: f.duration,
separator: f.separator,
state: Shown {},
}
}
}
impl From<Duration<Shown>> for Duration<Hidden> {
fn from(f: Duration<Shown>) -> Self {
f.duration.hide();
f.separator.hide();
Duration {
duration: f.duration,
separator: f.separator,
state: Hidden {},
}
}
}
#[derive(Debug, Clone)]
pub enum DurationMachine {
Hidden(Duration<Hidden>),
Shown(Duration<Shown>),
}
impl DurationMachine {
pub fn new(duration: gtk::Label, separator: gtk::Label, seconds: Option<i32>) -> Self {
let m = DurationMachine::Hidden(Duration::<Hidden>::new(duration, separator));
m.determine_state(seconds)
}
pub fn determine_state(self, seconds: Option<i32>) -> Self {
match (self, seconds) {
(d @ DurationMachine::Hidden(_), None) => d,
(DurationMachine::Shown(val), None) => DurationMachine::Hidden(val.into()),
(DurationMachine::Hidden(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val)
} else {
val.set_duration(minutes);
DurationMachine::Shown(val.into())
}
}
(DurationMachine::Shown(val), Some(s)) => {
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
if minutes == 0 {
DurationMachine::Hidden(val.into())
} else {
val.set_duration(minutes);
DurationMachine::Shown(val)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct Size<S> {
size: gtk::Label,
separator: gtk::Label,
state: S,
}
impl<S> Size<S> {
fn set_size(self, s: &str) -> Size<Shown> {
self.size.set_text(s);
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_shown(self) -> Size<Shown> {
self.size.show();
self.separator.show();
Size {
size: self.size,
separator: self.separator,
state: Shown {},
}
}
fn into_hidden(self) -> Size<Hidden> {
self.size.hide();
self.separator.hide();
Size {
size: self.size,
separator: self.separator,
state: Hidden {},
}
}
}
impl Size<UnInitialized> {
fn new(size: gtk::Label, separator: gtk::Label) -> Self {
size.hide();
separator.hide();
Size {
size,
separator,
state: UnInitialized {},
}
}
}
// pub trait Playable {}
// impl Playable for Download {}
// impl Playable for Play {}
#[derive(Debug, Clone)]
pub struct Download;
#[derive(Debug, Clone)]
pub struct Play;
#[derive(Debug, Clone)]
// FIXME: Needs better name.
// Should each button also has it's own type and machine?
pub struct DownloadPlay<S> {
play: gtk::Button,
download: gtk::Button,
state: S,
}
impl<S> DownloadPlay<S> {
// https://play.rust-lang.org/?gist=1acffaf62743eeb85be1ae6ecf474784&version=stable
// It might be possible to make a generic definition with Specialization.
// https://github.com/rust-lang/rust/issues/31844
fn into_playable(self) -> DownloadPlay<Play> {
self.play.show();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Play {},
}
}
fn into_fetchable(self) -> DownloadPlay<Download> {
self.play.hide();
self.download.show();
DownloadPlay {
play: self.play,
download: self.download,
state: Download {},
}
}
fn into_hidden(self) -> DownloadPlay<Hidden> {
self.play.hide();
self.download.hide();
DownloadPlay {
play: self.play,
download: self.download,
state: Hidden {},
}
}
fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.download.connect_clicked(f)
}
fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.play.connect_clicked(f)
}
}
impl DownloadPlay<UnInitialized> {
fn new(play: gtk::Button, download: gtk::Button) -> Self {
play.hide();
download.hide();
DownloadPlay {
play,
download,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Progress<S> {
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
state: S,
}
impl<S> Progress<S> {
fn into_shown(self) -> Progress<Shown> {
self.bar.show();
self.cancel.show();
self.local_size.show();
self.prog_separator.show();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Shown {},
}
}
fn into_hidden(self) -> Progress<Hidden> {
self.bar.hide();
self.cancel.hide();
self.local_size.hide();
self.prog_separator.hide();
Progress {
bar: self.bar,
cancel: self.cancel,
local_size: self.local_size,
prog_separator: self.prog_separator,
state: Hidden {},
}
}
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.local_size.set_text(local_size);
self.bar.set_fraction(fraction);
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
self.cancel.connect_clicked(move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
})
}
}
impl Progress<UnInitialized> {
fn new(
bar: gtk::ProgressBar,
cancel: gtk::Button,
local_size: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
bar.hide();
cancel.hide();
local_size.hide();
prog_separator.hide();
Progress {
bar,
cancel,
local_size,
prog_separator,
state: UnInitialized {},
}
}
}
#[derive(Debug, Clone)]
pub struct Media<X, Y, Z> {
dl: DownloadPlay<X>,
size: Size<Y>,
progress: Progress<Z>,
}
type New<Y> = Media<Download, Y, Hidden>;
type Playable<Y> = Media<Play, Y, Hidden>;
type InProgress = Media<Hidden, Shown, Shown>;
type MediaUnInitialized = Media<UnInitialized, UnInitialized, UnInitialized>;
impl From<New<Shown>> for InProgress {
fn from(f: New<Shown>) -> Self {
f.into_progress()
}
}
impl From<New<Hidden>> for InProgress {
fn from(f: New<Hidden>) -> Self {
f.into_progress()
}
}
impl From<Playable<Shown>> for InProgress {
fn from(f: Playable<Shown>) -> Self {
f.into_progress()
}
}
impl From<Playable<Hidden>> for InProgress {
fn from(f: Playable<Hidden>) -> Self {
f.into_progress()
}
}
impl<Y: Visibility> From<Playable<Y>> for New<Y> {
fn from(f: Playable<Y>) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size,
progress: f.progress,
}
}
}
impl<Y: Visibility> From<New<Y>> for Playable<Y> {
fn from(f: New<Y>) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size,
progress: f.progress,
}
}
}
impl From<MediaUnInitialized> for New<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl From<MediaUnInitialized> for Playable<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl<X, Y, Z> Media<X, Y, Z> {
fn set_size(self, s: &str) -> Media<X, Shown, Z> {
Media {
dl: self.dl,
size: self.size.set_size(s),
progress: self.progress,
}
}
fn hide_size(self) -> Media<X, Hidden, Z> {
Media {
dl: self.dl,
size: self.size.into_hidden(),
progress: self.progress,
}
}
fn into_new(self, size: &str) -> New<Shown> {
Media {
dl: self.dl.into_fetchable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
fn into_playable(self, size: &str) -> Playable<Shown> {
Media {
dl: self.dl.into_playable(),
size: self.size.set_size(size),
progress: self.progress.into_hidden(),
}
}
}
impl<X, Z> Media<X, Shown, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.into_shown(),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, Hidden, Z> {
fn into_progress(self) -> InProgress {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
impl<X, Z> Media<X, UnInitialized, Z> {
fn into_progress(self, size: Option<String>) -> InProgress {
if let Some(s) = size {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size(&s),
progress: self.progress.into_shown(),
}
} else {
Media {
dl: self.dl.into_hidden(),
size: self.size.set_size("Unkown"),
progress: self.progress.into_shown(),
}
}
}
}
impl InProgress {
#[allow(unused_must_use)]
// This does not need to be &mut since gtk-rs does not model ownership
// But I think it wouldn't hurt if we treat it as a Rust api.
fn update_progress(&mut self, local_size: &str, fraction: f64) {
self.progress.update_progress(local_size, fraction)
}
}
#[derive(Debug, Clone)]
pub enum ButtonsState {
New(Media<Download, Shown, Hidden>),
NewWithoutSize(Media<Download, Hidden, Hidden>),
Playable(Media<Play, Shown, Hidden>),
PlayableWithoutSize(Media<Play, Hidden, Hidden>),
}
impl ButtonsState {
pub fn determine_state(self, size: Option<String>, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
match (self, size, is_downloaded) {
// From whatever to New
(New(m), Some(s), false) => New(m.into_new(&s)),
(Playable(m), Some(s), false) => New(m.into_new(&s)),
(NewWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s), false) => New(m.into_new(&s)),
// From whatever to Playable
(New(m), Some(s), true) => Playable(m.into_playable(&s)),
(Playable(m), Some(s), true) => Playable(m.into_playable(&s)),
(NewWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
(PlayableWithoutSize(m), Some(s), true) => Playable(m.into_playable(&s)),
// From whatever to NewWithoutSize
(New(m), None, false) => NewWithoutSize(m.hide_size()),
(Playable(m), None, false) => NewWithoutSize(Media::from(m).hide_size()),
(b @ NewWithoutSize(_), None, false) => b,
(PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into()),
// From whatever to PlayableWithoutSize
(New(m), None, true) => PlayableWithoutSize(Media::from(m).hide_size()),
(Playable(m), None, true) => PlayableWithoutSize(m.hide_size()),
(NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into()),
(b @ PlayableWithoutSize(_), None, true) => b,
// _ => unimplemented!()
}
}
fn into_progress(self) -> InProgress {
use self::ButtonsState::*;
match self {
New(m) => m.into(),
Playable(m) => m.into(),
NewWithoutSize(m) => m.into(),
PlayableWithoutSize(m) => m.into(),
}
}
fn set_size(self, size: Option<String>) -> Self {
use self::ButtonsState::*;
match (self, size) {
(New(m), Some(s)) => New(m.set_size(&s)),
(New(m), None) => NewWithoutSize(m.hide_size()),
(Playable(m), Some(s)) => Playable(m.set_size(&s)),
(Playable(m), None) => PlayableWithoutSize(m.hide_size()),
(bttn @ NewWithoutSize(_), None) => bttn,
(bttn @ PlayableWithoutSize(_), None) => bttn,
(NewWithoutSize(m), Some(s)) => New(m.into_new(&s)),
(PlayableWithoutSize(m), Some(s)) => Playable(m.into_playable(&s)),
}
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.download_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.download_connect_clicked(f),
Playable(ref val) => val.dl.download_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.dl.play_connect_clicked(f),
NewWithoutSize(ref val) => val.dl.play_connect_clicked(f),
Playable(ref val) => val.dl.play_connect_clicked(f),
PlayableWithoutSize(ref val) => val.dl.play_connect_clicked(f),
}
}
fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::ButtonsState::*;
match *self {
New(ref val) => val.progress.cancel_connect_clicked(prog),
NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
Playable(ref val) => val.progress.cancel_connect_clicked(prog),
PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
}
#[derive(Debug, Clone)]
pub enum MediaMachine {
UnInitialized(Media<UnInitialized, UnInitialized, UnInitialized>),
Initialized(ButtonsState),
InProgress(Media<Hidden, Shown, Shown>),
}
impl MediaMachine {
#[cfg_attr(feature = "cargo-clippy", allow(too_many_arguments))]
pub fn new(
play: gtk::Button,
download: gtk::Button,
bar: gtk::ProgressBar,
cancel: gtk::Button,
total_size: gtk::Label,
local_size: gtk::Label,
separator: gtk::Label,
prog_separator: gtk::Label,
) -> Self {
let dl = DownloadPlay::<UnInitialized>::new(play, download);
let progress = Progress::<UnInitialized>::new(bar, cancel, local_size, prog_separator);
let size = Size::<UnInitialized>::new(total_size, separator);
MediaMachine::UnInitialized(Media { dl, progress, size })
}
pub fn download_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.download_connect_clicked(f),
Initialized(ref val) => val.download_connect_clicked(f),
InProgress(ref val) => val.dl.download_connect_clicked(f),
}
}
pub fn play_connect_clicked<F: Fn(&gtk::Button) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.dl.play_connect_clicked(f),
Initialized(ref val) => val.play_connect_clicked(f),
InProgress(ref val) => val.dl.play_connect_clicked(f),
}
}
pub fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::MediaMachine::*;
match *self {
UnInitialized(ref val) => val.progress.cancel_connect_clicked(prog),
Initialized(ref val) => val.cancel_connect_clicked(prog),
InProgress(ref val) => val.progress.cancel_connect_clicked(prog),
}
}
pub fn determine_state(self, bytes: Option<i32>, is_active: bool, is_downloaded: bool) -> Self {
use self::ButtonsState::*;
use self::MediaMachine::*;
match (self, size_helper(bytes), is_downloaded, is_active) {
(UnInitialized(m), s, _, true) => InProgress(m.into_progress(s)),
// Into New
(UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
(UnInitialized(m), None, false, false) => Initialized(NewWithoutSize(m.into())),
// Into Playable
(UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
(UnInitialized(m), None, true, false) => Initialized(PlayableWithoutSize(m.into())),
(Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)),
(Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()),
(i @ InProgress(_), _, _, _) => i,
}
}
pub fn set_size(self, bytes: Option<i32>) -> Self {
use self::MediaMachine::*;
let size = size_helper(bytes);
match (self, size) {
(Initialized(bttn), s) => Initialized(bttn.set_size(s)),
(InProgress(val), Some(s)) => InProgress(val.set_size(&s)),
(n @ InProgress(_), None) => n,
(n @ UnInitialized(_), _) => n,
}
}
pub fn update_progress(&mut self, local_size: &str, fraction: f64) {
use self::MediaMachine::*;
match *self {
Initialized(_) => (),
UnInitialized(_) => (),
InProgress(ref mut val) => val.update_progress(local_size, fraction),
}
}
}
#[inline]
fn size_helper(bytes: Option<i32>) -> Option<String> {
let s = bytes?;
if s == 0 {
return None;
}
s.file_size(SIZE_OPTS.clone()).ok()
}
@@ -1,17 +1,25 @@
use chrono::prelude::*;
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::EpisodeWidgetQuery;
use crossbeam_channel::Sender;
use hammond_data::dbqueries;
use hammond_data::EpisodeWidgetModel;
use send_cell::SendCell;
use app::Action;
use utils::{get_ignored_shows, set_image_from_path};
use utils::{self, lazy_load_full};
use widgets::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::rc::Rc;
use std::sync::Mutex;
lazy_static! {
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
Mutex::new(None);
}
#[derive(Debug, Clone)]
enum ListSplit {
@@ -23,7 +31,7 @@ enum ListSplit {
}
#[derive(Debug, Clone)]
pub struct EpisodesView {
pub struct HomeView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box,
@@ -39,9 +47,9 @@ pub struct EpisodesView {
rest_list: gtk::ListBox,
}
impl Default for EpisodesView {
impl Default for HomeView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
@@ -56,7 +64,7 @@ impl Default for EpisodesView {
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
EpisodesView {
HomeView {
container,
scrolled_window,
frame_parent,
@@ -75,89 +83,76 @@ impl Default for EpisodesView {
}
// TODO: REFACTOR ME
impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
let view = EpisodesView::default();
let ignore = get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 50)?;
impl HomeView {
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
use self::ListSplit::*;
let view = Rc::new(HomeView::default());
let ignore = utils::get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
let now_utc = Utc::now();
episodes.into_iter().for_each(|ep| {
let view_ = view.clone();
let func = move |ep: EpisodeWidgetModel| {
let epoch = ep.epoch();
let viewep = EpisodesViewWidget::new(ep, sender.clone());
let widget = HomeEpisode::new(&ep, &sender);
let t = split(&now_utc, i64::from(epoch));
match t {
ListSplit::Today => {
view.today_list.add(&viewep.container);
}
ListSplit::Yday => {
view.yday_list.add(&viewep.container);
}
ListSplit::Week => {
view.week_list.add(&viewep.container);
}
ListSplit::Month => {
view.month_list.add(&viewep.container);
}
ListSplit::Rest => {
view.rest_list.add(&viewep.container);
}
match split(&now_utc, i64::from(epoch)) {
Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
}
});
};
if view.today_list.get_children().is_empty() {
view.today_box.hide();
}
if view.yday_list.get_children().is_empty() {
view.yday_box.hide();
}
if view.week_list.get_children().is_empty() {
view.week_box.hide();
}
if view.month_list.get_children().is_empty() {
view.month_box.hide();
}
if view.rest_list.get_children().is_empty() {
view.rest_box.hide();
}
let view_ = view.clone();
let callback = move || {
view_
.set_vadjustment()
.map_err(|err| format!("{}", err))
.ok();
};
lazy_load_full(episodes, func, callback);
view.container.show_all();
Ok(view)
}
pub fn is_empty(&self) -> bool {
if !self.today_list.get_children().is_empty() {
return false;
}
if !self.yday_list.get_children().is_empty() {
return false;
}
if !self.week_list.get_children().is_empty() {
return false;
}
if !self.month_list.get_children().is_empty() {
return false;
}
if !self.rest_list.get_children().is_empty() {
return false;
}
true
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
fn set_vadjustment(&self) -> Result<(), Error> {
let guard = EPISODES_VIEW_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = EPISODES_VIEW_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn add_to_box(widget: &HomeEpisode, listbox: &gtk::ListBox, box_: &gtk::Box) {
listbox.add(&widget.container);
box_.show();
}
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
@@ -177,22 +172,23 @@ fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
}
#[derive(Debug, Clone)]
struct EpisodesViewWidget {
struct HomeEpisode {
container: gtk::Box,
image: gtk::Image,
// FIXME: Change it to `EpisodeWidget` instead of a `Box`?
episode: gtk::Box,
}
impl Default for EpisodesViewWidget {
impl Default for HomeEpisode {
fn default() -> Self {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 6);
EpisodesViewWidget {
HomeEpisode {
container,
image,
episode: ep.container,
@@ -200,35 +196,34 @@ impl Default for EpisodesViewWidget {
}
}
impl EpisodesViewWidget {
fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
impl HomeEpisode {
fn new(episode: &EpisodeWidgetModel, sender: &Sender<Action>) -> HomeEpisode {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let pid = episode.podcast_id();
let ep = EpisodeWidget::new(episode, sender.clone());
let pid = episode.show_id();
let ep = EpisodeWidget::new(episode, sender);
let view = EpisodesViewWidget {
let view = HomeEpisode {
container,
image,
episode: ep.container,
episode: ep.container.clone(),
};
view.init(pid);
view
}
fn init(&self, podcast_id: i32) {
if let Err(err) = self.set_cover(podcast_id) {
error!("Failed to set a cover: {}", err)
}
fn init(&self, show_id: i32) {
self.set_cover(show_id)
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
self.container.pack_start(&self.episode, true, true, 6);
}
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
let pd = Arc::new(dbqueries::get_podcast_cover_from_id(podcast_id)?);
set_image_from_path(&self.image, pd, 64)
fn set_cover(&self, show_id: i32) -> Result<(), Error> {
utils::set_image_from_path(&self.image, show_id, 64)
}
}
+12 -3
View File
@@ -1,7 +1,16 @@
mod show;
mod aboutdialog;
pub mod appnotif;
mod empty;
mod episode;
mod episode_states;
mod home_view;
pub mod player;
mod show;
mod shows_view;
pub use self::aboutdialog::about_dialog;
pub use self::empty::EmptyView;
pub use self::episode::EpisodeWidget;
pub use self::home_view::HomeView;
pub use self::show::ShowWidget;
pub use self::show::mark_all_watched;
pub use self::show::{mark_all_notif, remove_show_notif};
pub use self::shows_view::ShowsView;
+460
View File
@@ -0,0 +1,460 @@
use gst::prelude::*;
use gst::ClockTime;
use gst_player;
use gtk;
use gtk::prelude::*;
use gio::{File, FileExt};
use glib::SignalHandlerId;
use chrono::NaiveTime;
use crossbeam_channel::Sender;
use failure::Error;
use send_cell::SendCell;
use hammond_data::{dbqueries, USER_AGENT};
use hammond_data::{EpisodeWidgetModel, ShowCoverModel};
use app::Action;
use utils::set_image_from_path;
use std::ops::Deref;
use std::path::Path;
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
enum SeekDirection {
Backwards,
Forward,
}
trait PlayerExt {
fn play(&self);
fn pause(&self);
fn stop(&self);
fn seek(&self, offset: ClockTime, direction: SeekDirection);
fn fast_forward(&self);
fn rewind(&self);
fn set_playback_rate(&self, f64);
}
#[derive(Debug, Clone)]
struct PlayerInfo {
container: gtk::Box,
show: gtk::Label,
episode: gtk::Label,
cover: gtk::Image,
}
impl PlayerInfo {
// FIXME: create a Diesel Model of the joined episode and podcast query instead
fn init(&self, episode: &EpisodeWidgetModel, podcast: &ShowCoverModel) {
self.set_cover_image(podcast);
self.set_show_title(podcast);
self.set_episode_title(episode);
}
fn set_episode_title(&self, episode: &EpisodeWidgetModel) {
self.episode.set_text(episode.title());
self.episode.set_tooltip_text(episode.title());
}
fn set_show_title(&self, show: &ShowCoverModel) {
self.show.set_text(show.title());
self.show.set_tooltip_text(show.title());
}
fn set_cover_image(&self, show: &ShowCoverModel) {
set_image_from_path(&self.cover, show.id(), 34)
.map_err(|err| error!("Player Cover: {}", err))
.ok();
}
}
#[derive(Debug, Clone)]
struct PlayerTimes {
container: gtk::Box,
progressed: gtk::Label,
duration: gtk::Label,
separator: gtk::Label,
slider: gtk::Scale,
slider_update: Rc<SignalHandlerId>,
}
#[derive(Debug, Clone, Copy)]
struct Duration(ClockTime);
impl Deref for Duration {
type Target = ClockTime;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone, Copy)]
struct Position(ClockTime);
impl Deref for Position {
type Target = ClockTime;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PlayerTimes {
/// Update the duration `gtk::Label` and the max range of the `gtk::SclaeBar`.
pub fn on_duration_changed(&self, duration: Duration) {
let seconds = duration.seconds().map(|v| v as f64).unwrap_or(0.0);
self.slider.block_signal(&self.slider_update);
self.slider.set_range(0.0, seconds);
self.slider.unblock_signal(&self.slider_update);
self.duration.set_text(&format_duration(seconds as u32));
}
/// Update the `gtk::SclaeBar` when the pipeline position is changed.
pub fn on_position_updated(&self, position: Position) {
let seconds = position.seconds().map(|v| v as f64).unwrap_or(0.0);
self.slider.block_signal(&self.slider_update);
self.slider.set_value(seconds);
self.slider.unblock_signal(&self.slider_update);
self.progressed.set_text(&format_duration(seconds as u32));
}
}
fn format_duration(seconds: u32) -> String {
let time = NaiveTime::from_num_seconds_from_midnight(seconds, 0);
if seconds >= 3600 {
time.format("%T").to_string()
} else {
time.format("%M:%S").to_string()
}
}
#[derive(Debug, Clone)]
struct PlayerRate {
radio150: gtk::RadioButton,
radio125: gtk::RadioButton,
radio_normal: gtk::RadioButton,
popover: gtk::Popover,
btn: gtk::MenuButton,
label: gtk::Label,
}
#[derive(Debug, Clone)]
struct PlayerControls {
container: gtk::Box,
play: gtk::Button,
pause: gtk::Button,
forward: gtk::Button,
rewind: gtk::Button,
}
#[derive(Debug, Clone)]
pub struct PlayerWidget {
pub action_bar: gtk::ActionBar,
player: gst_player::Player,
controls: PlayerControls,
timer: PlayerTimes,
info: PlayerInfo,
rate: PlayerRate,
}
impl Default for PlayerWidget {
fn default() -> Self {
let dispatcher = gst_player::PlayerGMainContextSignalDispatcher::new(None);
let player = gst_player::Player::new(
None,
// Use the gtk main thread
Some(&dispatcher.upcast::<gst_player::PlayerSignalDispatcher>()),
);
let mut config = player.get_config();
config.set_user_agent(USER_AGENT);
config.set_position_update_interval(250);
player.set_config(config).unwrap();
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/player_toolbar.ui");
let action_bar = builder.get_object("action_bar").unwrap();
let buttons = builder.get_object("buttons").unwrap();
let play = builder.get_object("play_button").unwrap();
let pause = builder.get_object("pause_button").unwrap();
let forward = builder.get_object("ff_button").unwrap();
let rewind = builder.get_object("rewind_button").unwrap();
let controls = PlayerControls {
container: buttons,
play,
pause,
forward,
rewind,
};
let timer_container = builder.get_object("timer").unwrap();
let progressed = builder.get_object("progress_time_label").unwrap();
let duration = builder.get_object("total_duration_label").unwrap();
let separator = builder.get_object("separator").unwrap();
let slider: gtk::Scale = builder.get_object("seek").unwrap();
slider.set_range(0.0, 1.0);
let slider_update = Rc::new(Self::connect_update_slider(&slider, &player));
let timer = PlayerTimes {
container: timer_container,
progressed,
duration,
separator,
slider,
slider_update,
};
let labels = builder.get_object("info").unwrap();
let show = builder.get_object("show_label").unwrap();
let episode = builder.get_object("episode_label").unwrap();
let cover = builder.get_object("show_cover").unwrap();
let info = PlayerInfo {
container: labels,
show,
episode,
cover,
};
let radio150 = builder.get_object("rate_1_50").unwrap();
let radio125 = builder.get_object("rate_1_25").unwrap();
let radio_normal = builder.get_object("normal_rate").unwrap();
let popover = builder.get_object("rate_popover").unwrap();
let btn = builder.get_object("rate_button").unwrap();
let label = builder.get_object("rate_label").unwrap();
let rate = PlayerRate {
radio150,
radio125,
radio_normal,
popover,
label,
btn,
};
PlayerWidget {
player,
action_bar,
controls,
timer,
info,
rate,
}
}
}
impl PlayerWidget {
pub fn new(sender: &Sender<Action>) -> Rc<Self> {
let w = Rc::new(Self::default());
Self::init(&w, sender);
w
}
fn init(s: &Rc<Self>, sender: &Sender<Action>) {
Self::connect_control_buttons(s);
Self::connect_rate_buttons(s);
Self::connect_gst_signals(s, sender);
}
/// Connect the `PlayerControls` buttons to the `PlayerExt` methods.
fn connect_control_buttons(s: &Rc<Self>) {
let weak = Rc::downgrade(s);
// Connect the play button to the gst Player.
s.controls.play.connect_clicked(clone!(weak => move |_| {
weak.upgrade().map(|p| p.play());
}));
// Connect the pause button to the gst Player.
s.controls.pause.connect_clicked(clone!(weak => move |_| {
weak.upgrade().map(|p| p.pause());
}));
// Connect the rewind button to the gst Player.
s.controls.rewind.connect_clicked(clone!(weak => move |_| {
weak.upgrade().map(|p| p.rewind());
}));
// Connect the fast-forward button to the gst Player.
s.controls.forward.connect_clicked(clone!(weak => move |_| {
weak.upgrade().map(|p| p.fast_forward());
}));
}
#[cfg_attr(rustfmt, rustfmt_skip)]
fn connect_gst_signals(s: &Rc<Self>, sender: &Sender<Action>) {
// Log gst warnings.
s.player.connect_warning(move |_, warn| warn!("gst warning: {}", warn));
// Log gst errors.
s.player.connect_error(clone!(sender => move |_, error| {
// FIXME: should never occur and should not be user facing.
sender.send(Action::ErrorNotification(format!("Player Error: {}", error)));
}));
// The followign callbacks require `Send` but are handled by the gtk main loop
let weak = SendCell::new(Rc::downgrade(s));
// Update the duration label and the slider
s.player.connect_duration_changed(clone!(weak => move |_, clock| {
weak.borrow()
.upgrade()
.map(|p| p.timer.on_duration_changed(Duration(clock)));
}));
// Update the position label and the slider
s.player.connect_position_updated(clone!(weak => move |_, clock| {
weak.borrow()
.upgrade()
.map(|p| p.timer.on_position_updated(Position(clock)));
}));
// Reset the slider to 0 and show a play button
s.player.connect_end_of_stream(clone!(weak => move |_| {
weak.borrow()
.upgrade()
.map(|p| p.stop());
}));
}
#[cfg_attr(rustfmt, rustfmt_skip)]
fn connect_rate_buttons(s: &Rc<Self>) {
let weak = Rc::downgrade(s);
s.rate
.radio_normal
.connect_toggled(clone!(weak => move |_| {
weak.upgrade().map(|p| p.on_rate_changed(1.00));
}));
s.rate
.radio125
.connect_toggled(clone!(weak => move |_| {
weak.upgrade().map(|p| p.on_rate_changed(1.25));
}));
s.rate
.radio150
.connect_toggled(clone!(weak => move |_| {
weak.upgrade().map(|p| p.on_rate_changed(1.50));
}));
}
fn on_rate_changed(&self, rate: f64) {
self.set_playback_rate(rate);
self.rate.label.set_text(&format!("{:.2}x", rate));
}
fn reveal(&self) {
self.action_bar.show();
}
pub fn initialize_episode(&self, rowid: i32) -> Result<(), Error> {
let ep = dbqueries::get_episode_widget_from_rowid(rowid)?;
let pd = dbqueries::get_podcast_cover_from_id(ep.show_id())?;
self.info.init(&ep, &pd);
// Currently that will always be the case since the play button is
// only shown if the file is downloaded
if let Some(ref path) = ep.local_uri() {
if Path::new(path).exists() {
// path is an absolute fs path ex. "foo/bar/baz".
// Convert it so it will have a "file:///"
// FIXME: convert it properly
if let Some(uri) = File::new_for_path(path).get_uri() {
// play the file
self.player.set_uri(&uri);
self.play();
return Ok(());
}
}
// TODO: log an error
}
// FIXME: Stream stuff
// unimplemented!()
Ok(())
}
fn connect_update_slider(slider: &gtk::Scale, player: &gst_player::Player) -> SignalHandlerId {
slider.connect_value_changed(clone!(player => move |slider| {
let value = slider.get_value() as u64;
player.seek(ClockTime::from_seconds(value));
}))
}
}
impl PlayerExt for PlayerWidget {
fn play(&self) {
self.reveal();
self.controls.pause.show();
self.controls.play.hide();
self.player.play();
}
fn pause(&self) {
self.controls.pause.hide();
self.controls.play.show();
self.player.pause();
// Only rewind on pause if the stream position is passed a certain point.
if let Some(sec) = self.player.get_position().seconds() {
if sec >= 90 {
self.seek(ClockTime::from_seconds(5), SeekDirection::Backwards);
}
}
}
#[cfg_attr(rustfmt, rustfmt_skip)]
fn stop(&self) {
self.controls.pause.hide();
self.controls.play.show();
self.player.stop();
// Reset the slider bar to the start
self.timer.on_position_updated(Position(ClockTime::from_seconds(0)));
}
// Adapted from https://github.com/philn/glide/blob/b52a65d99daeab0b487f79a0e1ccfad0cd433e22/src/player_context.rs#L219-L245
fn seek(&self, offset: ClockTime, direction: SeekDirection) {
let position = self.player.get_position();
if position.is_none() || offset.is_none() {
return;
}
let duration = self.player.get_duration();
let destination = match direction {
SeekDirection::Backwards if position >= offset => Some(position - offset),
SeekDirection::Forward if !duration.is_none() && position + offset <= duration => {
Some(position + offset)
}
_ => None,
};
destination.map(|d| self.player.seek(d));
}
// FIXME: make the interval a GSetting
fn rewind(&self) {
self.seek(ClockTime::from_seconds(10), SeekDirection::Backwards)
}
// FIXME: make the interval a GSetting
fn fast_forward(&self) {
self.seek(ClockTime::from_seconds(10), SeekDirection::Forward)
}
fn set_playback_rate(&self, rate: f64) {
self.player.set_rate(rate);
}
}
+210 -69
View File
@@ -1,19 +1,30 @@
use failure::Error;
// use glib;
use glib;
use gtk;
use gtk::prelude::*;
use html2pango::markup_from_raw;
use open;
use hammond_data::Podcast;
use crossbeam_channel::Sender;
use failure::Error;
use html2text;
use open;
use rayon;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::utils::delete_show;
use hammond_data::Show;
use app::Action;
use utils::set_image_from_path;
use widgets::episode::episodes_listbox;
use utils::{self, lazy_load};
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
lazy_static! {
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
Mutex::new(None);
}
#[derive(Debug, Clone)]
pub struct ShowWidget {
@@ -24,15 +35,16 @@ pub struct ShowWidget {
link: gtk::Button,
settings: gtk::MenuButton,
unsub: gtk::Button,
episodes: gtk::Frame,
episodes: gtk::ListBox,
show_id: Option<i32>,
}
impl Default for ShowWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let episodes: gtk::Frame = builder.get_object("episodes").unwrap();
let episodes = builder.get_object("episodes").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap();
let description: gtk::Label = builder.get_object("description").unwrap();
@@ -49,45 +61,46 @@ impl Default for ShowWidget {
link,
settings,
episodes,
show_id: None,
}
}
}
impl ShowWidget {
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> ShowWidget {
let pdw = ShowWidget::default();
pdw.init(pd, sender);
pub fn new(pd: Arc<Show>, sender: Sender<Action>) -> Rc<ShowWidget> {
let mut pdw = ShowWidget::default();
pdw.init(&pd, &sender);
let pdw = Rc::new(pdw);
populate_listbox(&pdw, pd, sender)
.map_err(|err| error!("Failed to populate the listbox: {}", err))
.ok();
pdw
}
pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
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());
pub fn init(&mut self, pd: &Arc<Show>, sender: &Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| {
if let Err(err) = on_unsub_button_clicked(pd.clone(), bttn, sender.clone()) {
error!("Error: {}", err);
}
on_unsub_button_clicked(pd.clone(), bttn, &sender);
}));
self.setup_listbox(&pd, sender.clone());
self.set_description(pd.description());
self.show_id = Some(pd.id());
if let Err(err) = self.set_cover(pd.clone()) {
error!("Failed to set a cover: {}", err)
}
self.set_cover(&pd)
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
let link = pd.link().to_owned();
self.link.set_tooltip_text(Some(link.as_str()));
self.link.connect_clicked(move |_| {
info!("Opening link: {}", &link);
if let Err(err) = open::that(&link) {
error!("Failed to open link: {}", &link);
error!("Error: {}", err);
}
open::that(&link)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed open link: {}", &link))
.ok();
});
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
@@ -98,80 +111,209 @@ impl ShowWidget {
on_played_button_clicked(
pd.clone(),
&episodes,
sender.clone()
&sender
)
}));
self.settings.set_popover(&show_menu);
}
/// Populate the listbox with the shows episodes.
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
let listbox = episodes_listbox(pd, sender.clone());
listbox.ok().map(|l| self.episodes.add(&l));
}
/// Set the show cover.
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
set_image_from_path(&self.cover, Arc::new(pd.into()), 128)
fn set_cover(&self, pd: &Arc<Show>) -> Result<(), Error> {
utils::set_image_from_path(&self.cover, pd.id(), 256)
}
/// Set the descripton text.
fn set_description(&self, text: &str) {
self.description.set_markup(&markup_from_raw(text));
self.description
.set_markup(&html2text::from_read(text.as_bytes(), 70));
}
/// Save the scrollabar vajustment to the cache.
pub fn save_vadjustment(&self, oldid: i32) -> Result<(), Error> {
if let Ok(mut guard) = SHOW_WIDGET_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some((oldid, SendCell::new(adj)));
debug!("Widget Alignment was saved with ID: {}.", oldid);
}
Ok(())
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
fn set_vadjustment(&self, pd: &Arc<Show>) -> Result<(), Error> {
let guard = SHOW_WIDGET_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some((oldid, ref sendcell)) = *guard {
// Only copy the old scrollbar if both widget's represent the same podcast.
debug!("PID: {}", pd.id());
debug!("OLDID: {}", oldid);
if pd.id() != oldid {
debug!("Early return");
return Ok(());
};
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
pub fn show_id(&self) -> Option<i32> {
self.show_id
}
}
fn on_unsub_button_clicked(
pd: Arc<Podcast>,
unsub_button: &gtk::Button,
/// Populate the listbox with the shows episodes.
fn populate_listbox(
show: &Rc<ShowWidget>,
pd: Arc<Show>,
sender: Sender<Action>,
) -> Result<(), Error> {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.hide();
sender.send(Action::RemoveShow(pd))?;
use crossbeam_channel::bounded;
sender.send(Action::HeaderBarNormal)?;
sender.send(Action::ShowShowsAnimated)?;
// Queue a refresh after the switch to avoid blocking the db.
sender.send(Action::RefreshShowsView)?;
sender.send(Action::RefreshEpisodesView)?;
let count = dbqueries::get_pd_episodes_count(&pd)?;
let (sender_, receiver) = bounded(1);
rayon::spawn(clone!(pd => move || {
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
// The receiver can be dropped if there's an early return
// like on show without episodes for example.
sender_.send(episodes);
}));
if count == 0 {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
show.episodes.add(&container);
return Ok(());
}
let show_ = show.clone();
gtk::idle_add(move || {
let episodes = match receiver.try_recv() {
Some(e) => e,
None => return glib::Continue(true),
};
let list = show_.episodes.clone();
let constructor = clone!(sender => move |ep| {
EpisodeWidget::new(&ep, &sender).container.clone()
});
let callback = clone!(pd, show_ => move || {
show_.set_vadjustment(&pd)
.map_err(|err| error!("Failed to set ShowWidget Alignment: {}", err))
.ok();
});
lazy_load(episodes, list.clone(), constructor, callback);
glib::Continue(false)
});
Ok(())
}
fn on_played_button_clicked(pd: Arc<Podcast>, episodes: &gtk::Frame, sender: Sender<Action>) {
fn on_unsub_button_clicked(pd: Arc<Show>, unsub_button: &gtk::Button, sender: &Sender<Action>) {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.set_sensitive(false);
sender.send(Action::RemoveShow(pd));
sender.send(Action::HeaderBarNormal);
sender.send(Action::ShowShowsAnimated);
// Queue a refresh after the switch to avoid blocking the db.
sender.send(Action::RefreshShowsView);
sender.send(Action::RefreshEpisodesView);
unsub_button.set_sensitive(true);
}
fn on_played_button_clicked(pd: Arc<Show>, episodes: &gtk::ListBox, sender: &Sender<Action>) {
if dim_titles(episodes).is_none() {
error!("Something went horribly wrong when dimming the titles.");
warn!("RUN WHILE YOU STILL CAN!");
}
sender.send(Action::MarkAllPlayerNotification(pd)).unwrap();
sender.send(Action::MarkAllPlayerNotification(pd))
}
pub fn mark_all_watched(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
fn mark_all_watched(pd: &Show, sender: &Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?;
sender.send(Action::RefreshWidgetIfVis)?;
sender.send(Action::RefreshEpisodesView)?;
// Not all widgets migth have been loaded when the mark_all is hit
// So we will need to refresh again after it's done.
sender.send(Action::RefreshWidgetIfSame(pd.id()));
sender.send(Action::RefreshEpisodesView);
Ok(())
}
pub fn mark_all_notif(pd: Arc<Show>, sender: &Sender<Action>) -> InAppNotification {
let id = pd.id();
let callback = clone!(sender => move || {
mark_all_watched(&pd, &sender)
.map_err(|err| error!("Notif Callback Error: {}", err))
.ok();
glib::Continue(false)
});
let undo_callback = clone!(sender => move || sender.send(Action::RefreshWidgetIfSame(id)));
let text = "Marked all episodes as listened";
InAppNotification::new(text, callback, undo_callback, UndoState::Shown)
}
pub fn remove_show_notif(pd: Arc<Show>, sender: Sender<Action>) -> InAppNotification {
let text = format!("Unsubscribed from {}", pd.title());
utils::ignore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not insert {} to the ignore list.", pd.title()))
.ok();
let callback = clone!(pd, sender => move || {
utils::uningore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not remove {} from the ignore list.", pd.title()))
.ok();
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd, sender => move || {
delete_show(&pd)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete {}", pd.title()))
.ok();
sender.send(Action::RefreshEpisodesView);
}));
glib::Continue(false)
});
let undo_callback = move || {
utils::uningore_show(pd.id())
.map_err(|err| error!("{}", err))
.ok();
sender.send(Action::RefreshShowsView);
sender.send(Action::RefreshEpisodesView);
};
InAppNotification::new(&text, callback, undo_callback, UndoState::Shown)
}
// Ideally if we had a custom widget this would have been as simple as:
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
// But now I can't think of a better way to do it than hardcoding the title
// position relative to the EpisodeWidget container gtk::Box.
fn dim_titles(episodes: &gtk::Frame) -> Option<()> {
let listbox = episodes
.get_children()
.remove(0)
.downcast::<gtk::ListBox>()
.ok()?;
let children = listbox.get_children();
fn dim_titles(episodes: &gtk::ListBox) -> Option<()> {
let children = episodes.get_children();
for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
@@ -182,8 +324,7 @@ fn dim_titles(episodes: &gtk::Frame) -> Option<()> {
.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()?;
let title = bar.get_children().remove(0).downcast::<gtk::Label>().ok()?;
title.get_style_context().map(|c| c.add_class("dim-label"));
}
+168
View File
@@ -0,0 +1,168 @@
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::Show;
use app::Action;
use utils::{self, get_ignored_shows, lazy_load, set_image_from_path};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
lazy_static! {
static ref SHOWS_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> = Mutex::new(None);
}
#[derive(Debug, Clone)]
pub struct ShowsView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsView {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsView {
pub fn new(sender: Sender<Action>) -> Result<Rc<Self>, Error> {
let pop = Rc::new(ShowsView::default());
pop.init(sender);
// Populate the flowbox with the Shows.
populate_flowbox(&pop)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) {
self.flowbox.connect_child_activated(move |_, child| {
on_child_activate(child, &sender)
.map_err(|err| error!("Error along flowbox child activation: {}", err))
.ok();
});
}
/// Set scrolled window vertical adjustment.
#[allow(unused)]
fn set_vadjustment(&self) -> Result<(), Error> {
let guard = SHOWS_VIEW_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = SHOWS_VIEW_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn populate_flowbox(shows: &Rc<ShowsView>) -> Result<(), Error> {
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
let constructor = move |parent| ShowsChild::new(&parent).child;
let callback = clone!(shows => move || {
shows.set_vadjustment()
.map_err(|err| error!("Failed to set ShowsView Alignment: {}", err))
.ok();
});
let flowbox = shows.flowbox.clone();
lazy_load(podcasts, flowbox, constructor, callback);
Ok(())
}
fn on_child_activate(child: &gtk::FlowBoxChild, sender: &Sender<Action>) -> Result<(), Error> {
use gtk::WidgetExt;
// This is such an ugly hack...
let id = WidgetExt::get_name(child)
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.parse::<i32>()?;
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
sender.send(Action::HeaderBarShowTile(pd.title().into()));
sender.send(Action::ReplaceWidget(pd));
sender.send(Action::ShowWidgetAnimated);
Ok(())
}
#[derive(Debug)]
struct ShowsChild {
container: gtk::Box,
cover: gtk::Image,
child: gtk::FlowBoxChild,
}
impl Default for ShowsChild {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/shows_child.ui");
let container: gtk::Box = builder.get_object("fb_child").unwrap();
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
let child = gtk::FlowBoxChild::new();
child.add(&container);
ShowsChild {
container,
cover,
child,
}
}
}
impl ShowsChild {
pub fn new(pd: &Show) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: &Show) {
self.container.set_tooltip_text(pd.title());
WidgetExt::set_name(&self.child, &pd.id().to_string());
self.set_cover(pd.id())
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
}
fn set_cover(&self, show_id: i32) -> Result<(), Error> {
set_image_from_path(&self.cover, show_id, 256)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
</p>
</description>
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
<bug-database rdf:resource="https://gitlab.gnome.org/alatiera/Hammond/issues" />
<bug-database rdf:resource="https://gitlab.gnome.org/World/hammond/issues" />
<category rdf:resource="http://api.gnome.org/doap-extensions#apps" />
<programming-language>Rust</programming-language>
+1 -1
View File
@@ -3,7 +3,7 @@
project(
'hammond', 'rust',
version: '0.3.1',
version: '0.4.0',
license: 'GPLv3',
)
+6 -3
View File
@@ -1,7 +1,7 @@
{
"app-id" : "org.gnome.Hammond",
"runtime" : "org.gnome.Platform",
"runtime-version" : "master",
"runtime-version" : "3.28",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
@@ -20,6 +20,7 @@
"--share=ipc",
"--socket=x11",
"--socket=wayland",
"--socket=pulseaudio",
"--talk-name=org.freedesktop.Desktop"
],
"build-options" : {
@@ -28,7 +29,9 @@
"--share=network"
],
"env" : {
"CARGO_HOME" : "/run/build/Hammond/cargo"
"CARGO_HOME" : "/run/build/Hammond/cargo",
"RUST_BACKTRACE" : "1",
"DEBUG" : "true"
}
},
"modules" : [
@@ -38,7 +41,7 @@
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/alatiera/Hammond.git",
"url" : "https://gitlab.gnome.org/World/hammond.git",
"branch" : "master"
}
]
-4
View File
@@ -1,15 +1,11 @@
unstable_features = true
verbose = false
max_width = 100
comment_width = 100
wrap_comments = true
tab_spaces = 4
hard_tabs = false
newline_style = "Unix"
write_mode = "Overwrite"
condense_wildcard_suffixes = false
format_strings = true
normalize_comments = true
reorder_imports = true
reorder_imported_names = true
reorder_imports_in_group = true
+9 -1
View File
@@ -1,5 +1,13 @@
#!/bin/sh
export CARGO_HOME=$1/target/cargo-home
export RUSTFLAGS="--cfg rayon_unstable"
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
if [[ $DEBUG = true ]]
then
echo "DEBUG MODE"
cargo build -p hammond-gtk && cp $1/target/debug/hammond-gtk $2
else
echo "RELEASE MODE"
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
fi
+1
View File
@@ -13,6 +13,7 @@ cp -rf hammond-data $DIST
cp -rf hammond-gtk $DIST
cp -rf hammond-downloader $DIST
cp Cargo.toml $DIST
cp Cargo.lock $DIST
cp configure $DIST
cp meson.build $DIST
cp Hammond.doap $DIST
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/sh
file="/.flatpak-info"
if [ -f "$file" ]
then
# If you open a build terminal with gnome-builder
source /usr/lib/sdk/rust-stable/enable.sh
export CARGO_HOME=target/cargo-home/
fi
export RUSTFLAGS="--cfg rayon_unstable"
export RUSTBACKTRACE="1"
rustc --version
cargo --version
# cargo fmt --version
cargo build --all && \
cargo test -- --test-threads=1 && \
cargo test -- --test-threads=1 --ignored
# Rustfmt from the flatpak bundle tends to be outdated,
# will probably get better when rustfmt stabilizes
# cargo fmt --all -- --write-mode=diff
# cargo bench large