Author SHA1 Message Date
Jordan Petridis cd52c5afdf AboutEpisode: Initial commit. 2018-06-06 14:13:27 +03:00
159 changed files with 5405 additions and 11870 deletions
+3 -13
View File
@@ -1,19 +1,9 @@
target/ target/
**/*.rs.bk **/*.rs.bk
Cargo.lock
.vscode .vscode
*.ui~ *.ui~
resources.gresource resources.gresource
_build/ _build
build/
vendor/ vendor/
.criterion/ .criterion
org.gnome.*.json~
podcasts-gtk/po/gnome-podcasts.pot
# scripts/test.sh
target_*/
# flatpak-builder stuff
.flatpak-builder/
app/
repo/
+85 -37
View File
@@ -4,56 +4,90 @@ stages:
- review - review
variables: variables:
BUNDLE: "org.gnome.Podcasts.${CI_COMMIT_SHA}.flatpak" 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: "--cfg rayon_unstable"
RUST_BACKTRACE: "FULL"
before_script:
- apt-get update -yqq
- apt-get install -yqq --no-install-recommends build-essential libgtk-3-dev meson
- mkdir -p .cargo_cache
# Only stuff inside the repo directory can be cached
# Override the CARGO_HOME variable to force it location
- export CARGO_HOME="${PWD}/.cargo_cache"
script:
- rustc -Vv && cargo -Vv
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test -- --test-threads=1
- cargo test -- --test-threads=1 --ignored
<<: *cargo_cache
rust:stable:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
rust:nightly:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
only:
- schedule
- web
flatpak: flatpak:
image: "registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:master" image: registry.gitlab.gnome.org/gnome/gnome-runtime-images/rust_bundle:3.28
stage: "test" stage: test
variables:
MANIFEST_PATH: "org.gnome.PodcastsDevel.json"
FLATPAK_MODULE: "gnome-podcasts"
CONFIGURE_ARGS: "-Dprofile=development"
DBUS_ID: "org.gnome.PodcastsDevel"
script: script:
- flatpak-builder --stop-at=${FLATPAK_MODULE} app ${MANIFEST_PATH} - flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/World/podcasts/issues/55 # https://gitlab.gnome.org/World/hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage # Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app ${MANIFEST_PATH} glib-compile-resources --sourcedir=podcasts-gtk/resources/ podcasts-gtk/resources/resources.xml - flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
# Build the flatpak repo # Build the flatpak repo
- flatpak-builder --run app ${MANIFEST_PATH} meson --prefix=/app ${CONFIGURE_ARGS} _build - flatpak-builder --run app org.gnome.Hammond.json meson --prefix=/app --libdir=/app/lib _build
- flatpak-builder --run app ${MANIFEST_PATH} ninja -C _build install - flatpak-builder --run app org.gnome.Hammond.json ninja -C _build install
- flatpak-builder --finish-only app org.gnome.Hammond.json
# Run the tests - flatpak build-export repo app
- |
xvfb-run -a -s "-screen 0 1024x768x24" \
flatpak-builder --run \
--env=APP_ID="org.gnome.PodcastsDevel" \
--env=LOCALEDIR="./podcasts-gtk/po" \
--env=VERSION="0.0.0" \
--env=RUSTFLAGS="--cfg rayon_unstable" \
--env=CARGO_HOME="target/cargo-home" \
--env=CARGO_TARGET_DIR="target_test/" \
app ${MANIFEST_PATH} \
cargo test -j 1 -- --test-threads=1
# Create a flatpak bundle # Create a flatpak bundle
- flatpak-builder --finish-only app ${MANIFEST_PATH} - flatpak build-bundle repo ${BUNDLE} org.gnome.Hammond
- flatpak build-export repo app # Run the tests
- flatpak build-bundle repo ${BUNDLE} ${DBUS_ID} # - 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: artifacts:
paths: paths:
- $BUNDLE - $BUNDLE
expire_in: 2 days expire_in: 30 days
cache: cache:
key: "flatpak" # 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: paths:
- .flatpak-builder/cache/ - .flatpak-builder/cache/
- target/ - target/
- target_test/
review: review:
stage: review stage: review
@@ -70,8 +104,7 @@ review:
url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE} url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE}
on_stop: stop_review on_stop: stop_review
except: except:
- master@World/podcasts - master@World/hammond
- tags
stop_review: stop_review:
stage: review stage: review
@@ -82,8 +115,7 @@ stop_review:
name: review/$CI_COMMIT_REF_NAME name: review/$CI_COMMIT_REF_NAME
action: stop action: stop
except: except:
- master@World/podcasts - master@World/hammond
- tags
# Configure and run rustfmt on nightly # Configure and run rustfmt on nightly
# Exits and builds fails if on bad format # Exits and builds fails if on bad format
@@ -94,3 +126,19 @@ rustfmt:
- rustc -Vv && cargo -Vv - rustc -Vv && cargo -Vv
- cargo fmt --version - cargo fmt --version
- cargo fmt --all -- --check - cargo fmt --all -- --check
# Configure and run clippy on nightly
# Only fails on errors atm.
clippy:
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly"
stage: lint
variables:
RUSTFLAGS: "--cfg rayon_unstable"
CARGO_HOME: ".cargo_cache"
script:
- rustc --version && cargo --version
- cargo clippy --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all
<<: *cargo_cache
+1 -1
View File
@@ -18,7 +18,7 @@ Some common cases might be:
Steps to reproduce: Steps to reproduce:
1. Open GNOME Podcasts 1. Open Hammond
2. Do an action 2. Do an action
3. ... 3. ...
+6 -37
View File
@@ -1,40 +1,9 @@
# Steps to reproduce Detailed description of the issue. Put as much information as you can, potentially
<!-- with images showing the issue.
Explain in detail the steps on how the issue can be reproduced.
-->
1.
2.
3.
Reproducible in: Steps to reproduce:
<!--
Please test if the issue was already fixed in the unstable version of the app.
For that, follow these steps:
1. Make sure Flatpak is installed or install it following these steps https://flatpak.org/setup
2. Install the unstable version of the app following, flatpak bundles can be found in the CI artifacts.
If these steps failed, write in 'Other' the distribution you’re using and 1. Open Hammond
the version of the app. 2. Do an action
--> 3. ...
- 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"
+34 -33
View File
@@ -1,41 +1,42 @@
# Current problems Current problems
<!-- <!--
What are the problems that the current project has? What are the problems that the current project has?
For example: For example:
* User cannot use the keyboard to perform most common actions * User cannot use the keyboard to perform most common actions
or or
* User cannot see documents from cloud services * User cannot see documents from cloud services
--> -->
# Goals & use cases # Goals & use cases
<!-- <!--
What are the use cases that this proposal will cover? What are the end goals? What are the use cases that this proposal will cover? What are the end goals?
For example: For example:
* User needs to share a file with their friends. * User needs to share a file with their friends.
or or
* It should be easy to edit a picture within the app. * It should be easy to edit a picture within the app.
--> -->
# Requirements # Requirements
<!-- <!--
What does the solution needs to ensure for being succesful? What does the solution needs to ensure for being succesful?
For example: For example:
* Work on small form factors and touch * Work on small form factors and touch
or or
* Use the Meson build system and integrate with it * Use the Meson build system and integrate with it
--> -->
# Relevant art # Relevant art
<!-- <!--
Is there any product that has implemented something similar? Put links to other Is there any product that has implemented something similar? Put links to other
projects, pictures, links to other code, etc. projects, pictures, links to other code, etc.
--> -->
# Proposal & plan # Proposal & plan
<!-- What's the solution and how should be achieved? It can be split in smaller <!-- 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. --> tasks of minimum change, so they can be delivered across several releases. -->
/label ~"Epic"
/label ~"Epic"
+10 -17
View File
@@ -1,24 +1,17 @@
### Use cases Detailed description of the feature. Put as much information as you can.
<!--
Describe what problem(s) the user is experiencing and that this request
is trying to solve.
-->
Proposed Mockups:
### Desired behavior (Add mockups of the proposed feature)
<!-- Describe the desired functionality. -->
## Design Tasks
### Benefits of the solution * [ ] design tasks
<!-- List the possible benefits of the solution and how it fits in the project. -->
## Development Tasks
### Possible drawbacks * [ ] development tasks
<!--
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.
-->
<!-- Ignore the text under this line. --> ## QA Tasks
/label ~"Feature"
* [ ] qa (quality assurance) tasks
-1
View File
@@ -1 +0,0 @@
### Please attach a relevant issue to this MR, if this doesn't exist please create one.
+18 -116
View File
@@ -6,115 +6,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased] ## [Unreleased]
### Added: ### Added:
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown) [!33](https://gitlab.gnome.org/World/hammond/merge_requests/33)
### Changed: ### 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)
### Fixed: - The `EpisdeWidget` was refactored. [!38](https://gitlab.gnome.org/World/hammond/merge_requests/38)
### Removed:
### Translations:
## [0.4.5] - 2018-08-31
### Added:
- [OARS](https://hughsie.github.io/oars/) Tags where added for compatibility with Store clients b0c94dd9
- Daniel added support for Translations !46
- Svitozar Cherepii(@svito) created a [wiki page](https://wiki.gnome.org/Apps/Podcasts) 70e79e50
- Libhandy was added as a dependancy #70
- Development builds can now be installed in parallel with stable builds !64
### Changed:
- The update indication was moved to an In-App notification #72
- The app icon's accent color was changed from orange to red 0dfb4859
- The stack switcher in the Headerbar is now insesitive on Empty Views !63
### Fixed:
- Improved handling of HTTP redirections #64 !61 !62
- Fixed a major performance regression when loading show covers !67
- More refference cycles have been fixed !59
- OPML import dialog now exits properly and no longer keeps the application from shuting down !65
- Update action is disabled if there isn't something to update #71
### Translations:
- Added Finish 93696026
- Added Polish 1bd6efc0
- Added Turkish 73929f2d
- Added Spanish !46
- Added German 6b6c390c
- Added Galician 0060a634
- Added Indonesian ded0224f
- Added Korean 36f16963
## [0.4.4] - 2018-07-31
### Changed:
- `SendCell` crate was replaced with `Fragile`. (Jorda Petridis) 838320785ebbea94e009698b473495cfec076f54
- Update dependancies (Jorda Petridis) 91bea8551998b16e44e5358fdd43c53422bcc6f3
### Fixed:
- Fix more refference cycles. (Jorda Petridis) 3496df24f8d8bfa8c8a53d8f00262d42ee39b41c
- Actually fix cargo-vendor (Jorda Petridis)
## [0.4.3] - 2018-07-27
### Fixed:
- Fix the cargo vendor config for the tarball releash script. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
## [0.4.2] - 2018-07-27
### Changed:
- Minimum size requested by the Views. (Jorda Petridis) 7c96152f3f53f271247230dccf1c9cd5947b685f
### Fixed:
- Screenshot metadata in appstream data. (Jorda Petridis) a2440c19e11ca4dcdbcb67cd85259a41fe3754d6
## [0.4.1] - 2018-07-26
### Added:
- Custom icons for the fast-forward and rewind actions in the Player were added. (Tobias Bernard) e77000076b3d78b8625f4c7ef367376d0130ece6
- Hicolor and symbolic icons for the Application. (Tobias Bernard and Sam Hewitt) edae1b04801dba9d91d5d4145db79b287f0eec2c
- Basic prefferences dialog (Zander Brown). [34](https://gitlab.gnome.org/World/podcasts/merge_requests/34)
- Dbus service preperation. Not used till the MPRIS2 integration has landed. (Zander Brown) [42](https://gitlab.gnome.org/World/podcasts/merge_requests/42)
- Episodes and Images will only get drawn when needed. Big Performance impact. (Jordan Petridis) [43](https://gitlab.gnome.org/World/podcasts/merge_requests/43)
### Changed:
- The `ShowWidget` control button were moved to a secondary menu in the Headerbar. (Jordan Petridis) 536805791e336a3e112799be554706bb804d2bef
- EmptyView layout improvements. (Jorda Petridis) 3c3d6c1e7f15b88308a9054b15a6ca0d8fa233ce 518ea9c8b57885c44bda9c418b19fef26ae0e55d
- Improved the `AddButton` behavior. (Jorda Petridis) 67ab54f8203f19aad198dc49e935127d25432b41
### Fixed:
- A couple reffence cycles where fixed. (Jorda Petridis)
### Removed:
- The delay between the application startup and the `update_on_startup` action. (Jorda Petridis) 7569465a612ee5ef84d0e58f4e1010c8d14080d4
## [0.4.0] - 2018-07-04
### Added:
- Keyboard Shortcuts and a Shortcuts dialog were implemented. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
### Changed:
- The `FileChooser` of the OPML import was changed to use the `FileChooserNative` widget/API. (ZanderBrown)
[!33](https://gitlab.gnome.org/World/podcasts/merge_requests/33)
- The `EpisdeWidget` was refactored.
[!38](https://gitlab.gnome.org/World/podcasts/merge_requests/38)
- `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012 - `EpisdeWidget`'s progressbar was changed to be non-blocking and should feel way more responsive now. 9b0ac5b83dadecdff51cd398293afdf0d5276012
- An embeded audio player was implemented!
[!40](https://gitlab.gnome.org/World/podcasts/merge_requests/40)
- Various Database changes.
[!41](https://gitlab.gnome.org/World/podcasts/merge_requests/41)
### Fixed: ### Fixed:
- Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/podcasts/merge_requests/37) - Fixed a bug whre the about dialog would be unclosable. (ZanderBrown) [!37](https://gitlab.gnome.org/World/hammond/merge_requests/37)
### Removed:
## [0.3.4] - 2018-05-20 ## [0.3.4] - 2018-05-20
### Fixed: ### Fixed:
@@ -140,9 +42,9 @@ not being able to access any file.
### Changed: ### Changed:
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution. - Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/World/podcasts/issues/7) [#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. - Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/World/podcasts/issues/25) [#25](https://gitlab.gnome.org/World/hammond/issues/25)
- `Rayon` Threadpools are now used instead of unlimited one-off threads. - `Rayon` Threadpools are now used instead of unlimited one-off threads.
- `EpisdeWidget`s are now loaded asynchronously accross views. - `EpisdeWidget`s are now loaded asynchronously accross views.
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4 - `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
@@ -150,7 +52,7 @@ not being able to access any file.
- `ShowWidget` Description is inside a scrolled window now - `ShowWidget` Description is inside a scrolled window now
### Fixed: ### Fixed:
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/podcasts/issues/57) - `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 - Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
### Removed: ### Removed:
@@ -159,27 +61,27 @@ not being able to access any file.
## [0.3.1] - 2018-03-28 ## [0.3.1] - 2018-03-28
### Added: ### Added:
- Ability to mark all episodes of a Show as watched. - Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/World/podcasts/issues/47) [#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. - Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/World/podcasts/issues/49) [#49](https://gitlab.gnome.org/World/hammond/issues/49)
- Hammond now remembers the window size and position. (Rowan Lewis) - Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/World/podcasts/issues/50) [#50](https://gitlab.gnome.org/World/hammond/issues/50)
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis) - Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/World/podcasts/merge_requests/22) [!23](https://gitlab.gnome.org/World/podcasts/merge_requests/23) [!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. - Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/World/podcasts/issues/44) [#44](https://gitlab.gnome.org/World/hammond/issues/44)
### Changed: ### Changed:
- EpisdeWidget has been reimplemented as a compile time state machine. - EpisdeWidget has been reimplemented as a compile time state machine.
[!18](https://gitlab.gnome.org/World/podcasts/merge_requests/18) [!18](https://gitlab.gnome.org/World/hammond/merge_requests/18)
- Content Views no longer scroll horizontally when shrunk bellow their minimum size. - Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/World/podcasts/issues/35) [#35](https://gitlab.gnome.org/World/hammond/issues/35)
- Some requests now use the Tor Browser's user agent. (Rowan Lewis) - Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/World/podcasts/issues/53) [#53](https://gitlab.gnome.org/World/hammond/issues/53)
### Fixed: ### Fixed:
- Double border aroun the main window was fixed. (Rowan Lewis) - Double border aroun the main window was fixed. (Rowan Lewis)
[#52](https://gitlab.gnome.org/World/podcasts/issues/52) [#52](https://gitlab.gnome.org/World/hammond/issues/52)
## [0.3.0] - 2018-02-11 ## [0.3.0] - 2018-02-11
- Tobias Bernard Redesigned the whole Gtk+ client. - Tobias Bernard Redesigned the whole Gtk+ client.
+8 -8
View File
@@ -1,18 +1,18 @@
## Contributing to GNOME Podcasts ## Contributing to Hammond
Thank you for looking in this file! Thank you for looking in this file!
When contributing to the development of GNOME Podcasts, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change. When contributing to the development of Hammond, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
If you have any questions regarding the use or development of GNOME Podcasts, 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://irc.gnome.org/#hammond)
Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOfConduc), please follow it in all your interactions with the project. Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOfConduc), please follow it in all your interactions with the project.
## Source repository ## Source repository
GNOME Podcasts's main source repository is at gitlab.gnome.org. You can view Hammond's main source repository is at gitlab.gnome.org. You can view
the web interface [here](https://gitlab.gnome.org/World/podcasts) the web interface [here](https://gitlab.gnome.org/World/hammond)
Development happens in the master branch. Development happens in the master branch.
@@ -50,8 +50,8 @@ In order to run the test suite use the following: `cargo test -- --test-threads=
# Issues, issues and more issues! # Issues, issues and more issues!
There are many ways you can contribute to GNOME Podcasts, and all of them involve creating issues There are many ways you can contribute to Hammond, and all of them involve creating issues
in [GNOME Podcasts issue tracker](https://gitlab.gnome.org/World/podcasts/issues). This is the entry point for your contribution. in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/issues). This is the entry point for your contribution.
To create an effective and high quality ticket, try to put the following information on your To create an effective and high quality ticket, try to put the following information on your
ticket: ticket:
@@ -74,7 +74,7 @@ If it's an issue, add the steps to reproduce like this:
Steps to reproduce: Steps to reproduce:
1. Open GNOME Podcasts 1. Open Hammond
2. Do an Action 2. Do an Action
3. ... 3. ...
Generated
+815 -1133
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,8 +1,8 @@
[workspace] [workspace]
members = [ members = [
"podcasts-data", "hammond-data",
"podcasts-downloader", "hammond-downloader",
"podcasts-gtk" "hammond-gtk"
] ]
[profile.release] [profile.release]
+2 -2
View File
@@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
GNOME Podcasts Hammond
Copyright (C) 2017 Jordan Petridis Copyright (C) 2017 Jordan Petridis
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: notice like this when it starts in an interactive mode:
GNOME Podcasts Copyright (C) 2017 Jordan Petridis Hammond Copyright (C) 2017 Jordan Petridis
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.
+62 -60
View File
@@ -1,63 +1,66 @@
# GNOME Podcasts # Hammond
### A Podcast application for GNOME. ### A Podcast Client for GNOME written in Rust.
Listen to your favorite podcasts, right from your desktop.
![episdes_view](./screenshots/home_view.png) ![episdes_view](./screenshots/episodes_view.png)
![shows_view](./screenshots/shows_view.png) ![shows_view](./screenshots/shows_view.png)
![show_widget](./screenshots/show_widget.png) ![show_widget](./screenshots/show_widget.png)
## Available on Flathub
[![Get it from Flathub!](https://flathub.org/assets/badges/flathub-badge-en.svg)](https://flathub.org/apps/details/org.gnome.Podcasts)
## Quick start ## Quick start
GNOME Podcasts can be built and run with [Gnome Builder][builder] >= 3.28. Hammond can be built and run with [Gnome Builder](https://wiki.gnome.org/Apps/Builder) >= 3.28.
Just clone the repo and hit the run button!
You can get Builder from [here][get_builder]. Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
## Broken Feeds ## Broken Feeds
Found a feed that does not work in GNOME Podcasts? Found a feed that does not work in Hammond?
Please [open an issue][new_issue] and choose the `BrokenFeed` template so we will know and fix it! Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
## Getting in Touch ## Getting in Touch
If you have any questions regarding the use or development of GNOME Podcasts, If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us on our [irc][irc] or [matrix][matrix] channel. want to discuss design or simply hang out, please join us in `#hammond` on
[irc.gnome.org.][irc] or [matrix][matrix].
Note:
There isn't much documentation yet, so you will probably have question about parts of the Code.
## Building ## Building
### Flatpak ### Flatpak
Flatpak is the recommended way of building and installing GNOME Podcasts. Flatpak is the reccomended way of building and installing Hammond.
Here are the depencancies you will need.
```sh #### Building a Flatpak
# Add flathub and the gnome-nightly repo
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists gnome-nightly https://sdk.gnome.org/gnome-nightly.flatpakrepo
# Install the gnome-nightly Sdk and Platform runtime Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
flatpak install --user gnome-nightly org.gnome.Sdk org.gnome.Platform
```bash
# Add flathub repo
flatpak --user remote-add flathub --if-not-exists https://dl.flathub.org/repo/flathub.flatpakrepo
# Add the gnome-nightly repo
flatpak --user remote-add gnome-nightly --if-not-exists https://sdk.gnome.org/gnome-nightly.flatpakrepo
# Install the gnome-nightly Sdk and Platform runtim
flatpak --user install gnome-nightly org.gnome.Sdk org.gnome.Platform
# Install the required rust-stable extension from flathub # Install the required rust-stable extension from flathub
flatpak install --user flathub org.freedesktop.Sdk.Extension.rust-stable//18.08 flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
``` ```
To install the resulting flatpak you can do: To install the resulting flatpak you can do:
```bash ```bash
flatpak-builder --user --install --force-clean --repo=repo podcasts org.gnome.Podcasts.json flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
flatpak install --user --bundle hammond.flatpak
``` ```
### Building from source ### Building from soure
```sh ```sh
git clone https://gitlab.gnome.org/World/podcasts.git git clone https://gitlab.gnome.org/World/hammond.git
cd gnome-podcasts/ cd hammond/
meson --prefix=/usr build meson --prefix=/usr build
ninja -C build ninja -C build
sudo ninja -C build install sudo ninja -C build install
@@ -65,33 +68,41 @@ sudo ninja -C build install
#### Dependencies #### Dependencies
* Rust stable 1.27 or later along with cargo. * Rust stable 1.26 or later along with cargo.
* Gtk+ 3.22 or later * Gtk+ 3.22 or later
* Gstreamer 1.12 or later
* libhandy
* Meson * Meson
* A network connection * A network connection
Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first
**Debian/Ubuntu**
```sh
apt-get update -yqq
apt-get install -yqq --no-install-recommends build-essential
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
```
**Fedora**
```sh
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
```
If you happen to build it on other distributions please let me know the names
of the corresponding libraries. Feel free to open a MR or an Issue to note it.
## Contributing ## Contributing
There are a lot of things yet to be done. There alot of thins yet to be done.
If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines]. If you want to contribute, please check the [Contributions Guidelines][contribution-guidelines].
You can start by taking a look at [Issues][issues] or by opening a [New issue][new_issue]. You can start by taking a look at [Issues](https://gitlab.gnome.org/World/hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/World/hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code. There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source code.
[contribution-guidelines]: https://gitlab.gnome.org/World/podcasts/blob/master/CONTRIBUTING.md [contribution-guidelines]: https://gitlab.gnome.org/World/hammond/blob/master/CONTRIBUTING.md
### Translations
Translation of this project takes place on the GNOME translation platform,
[Damned Lies](https://l10n.gnome.org/module/podcasts). For further
information on how to join a language team, or even to create one, please see
[GNOME Translation Project wiki page](https://wiki.gnome.org/TranslationProject).
## Overview ## Overview
@@ -99,15 +110,15 @@ information on how to join a language team, or even to create one, please see
```sh ```sh
$ tree -d $ tree -d
├── screenshots # png's used in the README.md ├── screenshots # png's used in the README.md
├── podcasts-data # Storate related stuff, SQLite, XDG setup, RSS Parser. ├── hammond-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
│   ├── migrations # Diesel SQL migrations. │   ├── migrations # Diesel SQL migrations.
│   │   └── ... │   │   └── ...
│   ├── src │   ├── src
│   └── tests │   └── tests
│   └── feeds # Raw RSS Feeds used for tests. │   └── feeds # Raw RSS Feeds used for tests.
├── podcasts-downloader # Really basic, Really crappy downloader. ├── hammond-downloader # Really basic, Really crappy downloader.
│   └── src │   └── src
├── podcasts-gtk # The Gtk+ Client ├── hammond-gtk # The Gtk+ Client
│   ├── resources # GResources folder │   ├── resources # GResources folder
│   │   └── gtk # Contains the glade.ui files. │   │   └── gtk # Contains the glade.ui files.
│   └── src │   └── src
@@ -117,27 +128,18 @@ $ tree -d
## A note about the project's name ## A note about the project's name
The project used to be called Hammond, after Allan Moore's character [Evey Hammond][hammond] from the graphic novel V for Vendetta. The project was named after Allan Moore's character [Evey Hammond](https://en.wikipedia.org/wiki/Evey_Hammond) from the graphic novel V for Vendetta.
It was renamed to GNOME Podcasts on 2018/07/24 shortly before its first public release.
It has nothing to do with the horrible headlines on the news.
## Acknowledgments ## Acknowledgments
GNOME Podcasts's design is heavily inspired by [GNOME Music][music] and [Vocal][vocal]. Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Design/Apps/Music) and [Vocal](http://vocalproject.net/).
We also copied some elements from [GNOME News][news]. We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
And almost the entirety of the build system is copied from the [Fractal][fractal] project. And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
[vendor]: https://github.com/alexcrichton/cargo-vendor [vendor]: https://github.com/alexcrichton/cargo-vendor
[irc]: irc://irc.gnome.org/#hammond [irc]: irc://irc.gnome.org/#hammond
[matrix]: https://matrix.to/#/#gnome-podcasts:matrix.org [matrix]: https://matrix.to/#/#hammond:matrix.org
[flatpak_setup]: https://flatpak.org/setup/
[music]: https://wiki.gnome.org/Design/Apps/Music
[vocal]: http://vocalproject.net/
[news]: https://wiki.gnome.org/Design/Apps/Potential/News
[fractal]: https://gitlab.gnome.org/World/fractal
[hammond]: https://en.wikipedia.org/wiki/Evey_Hammond
[issues]: https://gitlab.gnome.org/World/podcasts/issues
[new_issue]: https://gitlab.gnome.org/World/podcasts/issues/new
[builder]: https://wiki.gnome.org/Apps/Builder
[get_builder]: https://wiki.gnome.org/Apps/Builder/Downloads
@@ -1,41 +1,46 @@
[package] [package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"] authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
name = "podcasts-data" name = "hammond-data"
version = "0.1.0" version = "0.1.0"
workspace = "../" workspace = "../"
[dependencies] [dependencies]
ammonia = "1.2.0" ammonia = "1.1.0"
chrono = "0.4.6" chrono = "0.4.2"
derive_builder = "0.5.1" derive_builder = "0.5.1"
lazy_static = "1.1.0" lazy_static = "1.0.0"
log = "0.4.4" log = "0.4.1"
rayon = "1.0.2" rayon = "1.0.1"
rayon-futures = "0.1.0" rayon-futures = "0.1.0"
rfc822_sanitizer = "0.3.3" rfc822_sanitizer = "0.3.3"
rss = "1.5.0" rss = "1.5.0"
url = "1.7.1" url = "1.7.0"
xdg = "2.1.0" xdg = "2.1.0"
xml-rs = "0.8.0" xml-rs = "0.8.0"
futures = "0.1.23" futures = "0.1.21"
hyper = "0.11.27" hyper = "0.11.27"
tokio-core = "0.1.17" tokio-core = "0.1.17"
hyper-tls = "0.1.3" hyper-tls = "0.1.3"
native-tls = "0.1.5" native-tls = "0.1.5"
num_cpus = "1.8.0" num_cpus = "1.8.0"
failure = "0.1.2" failure = "0.1.1"
failure_derive = "0.1.2" failure_derive = "0.1.1"
[dependencies.diesel] [dependencies.diesel]
features = ["sqlite", "r2d2"] features = ["sqlite", "r2d2"]
version = "1.3.2" version = "= 1.2.2"
[dependencies.diesel_migrations] [dependencies.diesel_migrations]
features = ["sqlite"] features = ["sqlite"]
version = "1.3.0" version = "= 1.2.0"
[dev-dependencies] [dev-dependencies]
rand = "0.5.5" rand = "0.5.0"
tempdir = "0.3.7" tempdir = "0.3.7"
criterion = "0.2.3"
pretty_assertions = "0.5.1" pretty_assertions = "0.5.1"
maplit = "1.0.1" maplit = "1.0.1"
[[bench]]
name = "bench"
harness = false
+104
View File
@@ -0,0 +1,104 @@
#![allow(unused)]
#[macro_use]
extern crate criterion;
use criterion::Criterion;
// extern crate futures;
extern crate hammond_data;
extern crate hyper;
extern crate hyper_tls;
extern crate rand;
extern crate tokio_core;
// extern crate rayon;
extern crate rss;
// use rayon::prelude::*;
// use futures::future::*;
use tokio_core::reactor::Core;
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
use hammond_data::FeedBuilder;
use hammond_data::Source;
// use hammond_data::errors::*;
use std::io::BufReader;
// RSS feeds
const INTERCEPTED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-Intercepted.xml");
const INTERCEPTED_URL: &str = "https://web.archive.org/web/20180120083840if_/https://feeds.\
feedburner.com/InterceptedWithJeremyScahill";
const UNPLUGGED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-LinuxUnplugged.xml");
const UNPLUGGED_URL: &str =
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged";
const TIPOFF: &[u8] = include_bytes!("../tests/feeds/2018-01-20-TheTipOff.xml");
const TIPOFF_URL: &str =
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// This feed has HUGE descripion and summary fields which can be very
// very expensive to parse.
const CODE: &[u8] = include_bytes!("../tests/feeds/2018-01-20-GreaterThanCode.xml");
const CODE_URL: &str =
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast";
// Relative small feed
const STARS: &[u8] = include_bytes!("../tests/feeds/2018-01-20-StealTheStars.xml");
const STARS_URL: &str =
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
static FEEDS: &[(&[u8], &str)] = &[
(INTERCEPTED, INTERCEPTED_URL),
(UNPLUGGED, UNPLUGGED_URL),
(TIPOFF, TIPOFF_URL),
(CODE, CODE_URL),
(STARS, STARS_URL),
];
fn bench_index_large_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://www.greaterthancode.com/feed/podcast";
let mut core = Core::new().unwrap();
c.bench_function("index_large_feed", move |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(CODE)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
});
truncate_db().unwrap();
}
fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://rss.art19.com/steal-the-stars";
let mut core = Core::new().unwrap();
c.bench_function("index_small_feed", move |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(STARS)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
});
truncate_db().unwrap();
}
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed);
criterion_main!(benches);
@@ -24,9 +24,7 @@ lazy_static! {
#[cfg(not(test))] #[cfg(not(test))]
lazy_static! { lazy_static! {
static ref DB_PATH: PathBuf = xdg_dirs::PODCASTS_XDG static ref DB_PATH: PathBuf = xdg_dirs::HAMMOND_XDG.place_data_file("hammond.db").unwrap();
.place_data_file("podcasts.db")
.unwrap();
} }
#[cfg(test)] #[cfg(test)]
@@ -34,8 +32,8 @@ extern crate tempdir;
#[cfg(test)] #[cfg(test)]
lazy_static! { lazy_static! {
static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("podcasts_unit_test").unwrap() }; static ref TEMPDIR: tempdir::TempDir = { tempdir::TempDir::new("hammond_unit_test").unwrap() };
static ref DB_PATH: PathBuf = TEMPDIR.path().join("podcasts.db"); static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
} }
/// Get an r2d2 `SqliteConnection`. /// Get an r2d2 `SqliteConnection`.
@@ -66,12 +64,12 @@ fn run_migration_on(connection: &SqliteConnection) -> Result<(), DataError> {
/// Reset the database into a clean state. /// Reset the database into a clean state.
// Test share a Temp file db. // Test share a Temp file db.
#[cfg(test)] #[allow(dead_code)]
pub fn truncate_db() -> Result<(), DataError> { pub fn truncate_db() -> Result<(), DataError> {
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
con.execute("DELETE FROM episodes")?; con.execute("DELETE FROM episode")?;
con.execute("DELETE FROM shows")?; con.execute("DELETE FROM podcast")?;
con.execute("DELETE FROM source")?; con.execute("DELETE FROM source")?;
Ok(()) Ok(())
} }
@@ -23,156 +23,169 @@ pub fn get_sources() -> Result<Vec<Source>, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_podcasts() -> Result<Vec<Show>, DataError> { pub fn get_podcasts() -> Result<Vec<Podcast>, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
shows podcast
.order(title.asc()) .order(title.asc())
.load::<Show>(&con) .load::<Podcast>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Show>, DataError> { pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
shows podcast
.order(title.asc()) .order(title.asc())
.filter(id.ne_all(filter_ids)) .filter(id.ne_all(filter_ids))
.load::<Show>(&con) .load::<Podcast>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episodes() -> Result<Vec<Episode>, DataError> { pub fn get_episodes() -> Result<Vec<Episode>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.order(epoch.desc()) .order(epoch.desc())
.load::<Episode>(&con) .load::<Episode>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> { pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.select((rowid, local_uri, played)) .select((rowid, local_uri, played))
.filter(local_uri.is_not_null()) .filter(local_uri.is_not_null())
.load::<EpisodeCleanerModel>(&con) .load::<EpisodeCleanerQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> { // pub(crate) fn get_played_episodes() -> Result<Vec<Episode>, DataError> {
// use schema::episodes::dsl::*; // use schema::episode::dsl::*;
// let db = connection(); // let db = connection();
// let con = db.get()?; // let con = db.get()?;
// episodes // episode
// .filter(played.is_not_null()) // .filter(played.is_not_null())
// .load::<Episode>(&con) // .load::<Episode>(&con)
// .map_err(From::from) // .map_err(From::from)
// } // }
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerModel>, DataError> { pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerQuery>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.select((rowid, local_uri, played)) .select((rowid, local_uri, played))
.filter(played.is_not_null()) .filter(played.is_not_null())
.load::<EpisodeCleanerModel>(&con) .load::<EpisodeCleanerQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> { pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.filter(rowid.eq(ep_id)) .filter(rowid.eq(ep_id))
.get_result::<Episode>(&con) .get_result::<Episode>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetModel, DataError> { pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetQuery, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.select(( .select((
rowid, title, uri, local_uri, epoch, length, duration, played, show_id, rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
)).filter(rowid.eq(ep_id)) ))
.get_result::<EpisodeWidgetModel>(&con) .filter(rowid.eq(ep_id))
.get_result::<EpisodeWidgetQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> { pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.filter(rowid.eq(ep_id)) .filter(rowid.eq(ep_id))
.select(local_uri) .select(local_uri)
.get_result::<Option<String>>(&con) .get_result::<Option<String>>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_description(rowid_: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.filter(rowid.eq(rowid_))
.select(description)
.get_result::<Option<String>>(&con)
.map_err(From::from)
}
pub fn get_episodes_widgets_filter_limit( pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32], filter_ids: &[i32],
limit: u32, limit: u32,
) -> Result<Vec<EpisodeWidgetModel>, DataError> { ) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
let columns = ( let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id, rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
); );
episodes episode
.select(columns) .select(columns)
.order(epoch.desc()) .order(epoch.desc())
.filter(show_id.ne_all(filter_ids)) .filter(podcast_id.ne_all(filter_ids))
.limit(i64::from(limit)) .limit(i64::from(limit))
.load::<EpisodeWidgetModel>(&con) .load::<EpisodeWidgetQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_podcast_from_id(pid: i32) -> Result<Show, DataError> { pub fn get_podcast_from_id(pid: i32) -> Result<Podcast, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
shows podcast
.filter(id.eq(pid)) .filter(id.eq(pid))
.get_result::<Show>(&con) .get_result::<Podcast>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_podcast_cover_from_id(pid: i32) -> Result<ShowCoverModel, DataError> { pub fn get_podcast_cover_from_id(pid: i32) -> Result<PodcastCoverQuery, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
shows podcast
.select((id, title, image_uri)) .select((id, title, image_uri))
.filter(id.eq(pid)) .filter(id.eq(pid))
.get_result::<ShowCoverModel>(&con) .get_result::<PodcastCoverQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_pd_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> { pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -182,7 +195,7 @@ pub fn get_pd_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_pd_episodes_count(parent: &Show) -> Result<i64, DataError> { pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -192,24 +205,24 @@ pub fn get_pd_episodes_count(parent: &Show) -> Result<i64, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_pd_episodeswidgets(parent: &Show) -> Result<Vec<EpisodeWidgetModel>, DataError> { pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
let columns = ( let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, show_id, rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
); );
episodes episode
.select(columns) .select(columns)
.filter(show_id.eq(parent.id())) .filter(podcast_id.eq(parent.id()))
.order(epoch.desc()) .order(epoch.desc())
.load::<EpisodeWidgetModel>(&con) .load::<EpisodeWidgetQuery>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_pd_unplayed_episodes(parent: &Show) -> Result<Vec<Episode>, DataError> { pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -220,8 +233,8 @@ pub fn get_pd_unplayed_episodes(parent: &Show) -> Result<Vec<Episode>, DataError
.map_err(From::from) .map_err(From::from)
} }
// pub(crate) fn get_pd_episodes_limit(parent: &Show, limit: u32) -> // pub(crate) fn get_pd_episodes_limit(parent: &Podcast, limit: u32) ->
// Result<Vec<Episode>, DataError> { use schema::episodes::dsl::*; // Result<Vec<Episode>, DataError> { use schema::episode::dsl::*;
// let db = connection(); // let db = connection();
// let con = db.get()?; // let con = db.get()?;
@@ -255,25 +268,25 @@ pub fn get_source_from_id(id_: i32) -> Result<Source, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_podcast_from_source_id(sid: i32) -> Result<Show, DataError> { pub fn get_podcast_from_source_id(sid: i32) -> Result<Podcast, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
shows podcast
.filter(source_id.eq(sid)) .filter(source_id.eq(sid))
.get_result::<Show>(&con) .get_result::<Podcast>(&con)
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> { pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.filter(title.eq(title_)) .filter(title.eq(title_))
.filter(show_id.eq(pid)) .filter(podcast_id.eq(pid))
.get_result::<Episode>(&con) .get_result::<Episode>(&con)
.map_err(From::from) .map_err(From::from)
} }
@@ -282,36 +295,19 @@ pub(crate) fn get_episode_minimal_from_pk(
title_: &str, title_: &str,
pid: i32, pid: i32,
) -> Result<EpisodeMinimal, DataError> { ) -> Result<EpisodeMinimal, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
episodes episode
.select((rowid, title, uri, epoch, length, duration, guid, show_id)) .select((rowid, title, uri, epoch, duration, guid, podcast_id))
.filter(title.eq(title_)) .filter(title.eq(title_))
.filter(show_id.eq(pid)) .filter(podcast_id.eq(pid))
.get_result::<EpisodeMinimal>(&con) .get_result::<EpisodeMinimal>(&con)
.map_err(From::from) .map_err(From::from)
} }
#[cfg(test)] pub(crate) fn remove_feed(pd: &Podcast) -> Result<(), DataError> {
pub(crate) fn get_episode_cleaner_from_pk(
title_: &str,
pid: i32,
) -> Result<EpisodeCleanerModel, DataError> {
use schema::episodes::dsl::*;
let db = connection();
let con = db.get()?;
episodes
.select((rowid, local_uri, played))
.filter(title.eq(title_))
.filter(show_id.eq(pid))
.get_result::<EpisodeCleanerModel>(&con)
.map_err(From::from)
}
pub(crate) fn remove_feed(pd: &Show) -> Result<(), DataError> {
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -330,16 +326,16 @@ fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
diesel::delete(source.filter(id.eq(source_id))).execute(con) diesel::delete(source.filter(id.eq(source_id))).execute(con)
} }
fn delete_podcast(con: &SqliteConnection, show_id: i32) -> QueryResult<usize> { fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
diesel::delete(shows.filter(id.eq(show_id))).execute(con) diesel::delete(podcast.filter(id.eq(podcast_id))).execute(con)
} }
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> { fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
diesel::delete(episodes.filter(show_id.eq(parent_id))).execute(con) diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(con)
} }
pub fn source_exists(url: &str) -> Result<bool, DataError> { pub fn source_exists(url: &str) -> Result<bool, DataError> {
@@ -354,84 +350,81 @@ pub fn source_exists(url: &str) -> Result<bool, DataError> {
} }
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> { pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
select(exists(shows.filter(source_id.eq(source_id_)))) select(exists(podcast.filter(source_id.eq(source_id_))))
.get_result(&con) .get_result(&con)
.map_err(From::from) .map_err(From::from)
} }
#[cfg_attr(rustfmt, rustfmt_skip)] #[cfg_attr(rustfmt, rustfmt_skip)]
pub(crate) fn episode_exists(title_: &str, show_id_: i32) -> Result<bool, DataError> { pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
select(exists(episodes.filter(show_id.eq(show_id_)).filter(title.eq(title_)))) select(exists(episode.filter(podcast_id.eq(podcast_id_)).filter(title.eq(title_))))
.get_result(&con) .get_result(&con)
.map_err(From::from) .map_err(From::from)
} }
/// Check if the `episodes table contains any rows pub fn episode_had_description(rowid_: i32) -> Result<bool, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.filter(rowid.eq(rowid_))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `episode` table contains any rows
/// ///
/// Return true if `episodes` table is populated. /// Return true if `episode` table is populated.
pub fn is_episodes_populated() -> Result<bool, DataError> { pub fn is_episodes_populated() -> Result<bool, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
select(exists(episodes.as_query())) select(exists(episode.as_query()))
.get_result(&con) .get_result(&con)
.map_err(From::from) .map_err(From::from)
} }
/// Check if the `shows` table contains any rows /// Check if the `podcast` table contains any rows
/// ///
/// Return true if `shows` table is populated. /// Return true if `podcast table is populated.
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> { pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
select(exists(shows.filter(id.ne_all(filter_ids)))) select(exists(podcast.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `source` table contains any rows
///
/// Return true if `source` table is populated.
pub fn is_source_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(source.filter(id.ne_all(filter_ids))))
.get_result(&con) .get_result(&con)
.map_err(From::from) .map_err(From::from)
} }
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> { pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
diesel::insert_into(episodes) diesel::insert_into(episode)
.values(eps) .values(eps)
.execute(&*con) .execute(&*con)
.map_err(From::from) .map_err(From::from)
.map(|_| ()) .map(|_| ())
} }
pub fn update_none_to_played_now(parent: &Show) -> Result<usize, DataError> { pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -448,26 +441,24 @@ pub fn update_none_to_played_now(parent: &Show) -> Result<usize, DataError> {
mod tests { mod tests {
use super::*; use super::*;
use database::*; use database::*;
use failure::Error;
use pipeline; use pipeline;
#[test] #[test]
fn test_update_none_to_played_now() -> Result<(), Error> { fn test_update_none_to_played_now() {
truncate_db()?; truncate_db().unwrap();
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\ let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill"; com/InterceptedWithJeremyScahill";
let source = Source::from_url(url)?; let source = Source::from_url(url).unwrap();
let id = source.id(); let id = source.id();
pipeline::run(vec![source])?; pipeline::run(vec![source], true).unwrap();
let pd = get_podcast_from_source_id(id)?; let pd = get_podcast_from_source_id(id).unwrap();
let eps_num = get_pd_unplayed_episodes(&pd)?.len(); let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_ne!(eps_num, 0); assert_ne!(eps_num, 0);
update_none_to_played_now(&pd)?; update_none_to_played_now(&pd).unwrap();
let eps_num2 = get_pd_unplayed_episodes(&pd)?.len(); let eps_num2 = get_pd_unplayed_episodes(&pd).unwrap().len();
assert_eq!(eps_num2, 0); assert_eq!(eps_num2, 0);
Ok(())
} }
} }
@@ -11,12 +11,7 @@ use std::io;
use models::Source; use models::Source;
#[fail( #[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
display = "Request to {} returned {}. Context: {}",
url,
status_code,
context
)]
#[derive(Fail, Debug)] #[derive(Fail, Debug)]
pub struct HttpStatusError { pub struct HttpStatusError {
url: String, url: String,
@@ -47,6 +42,7 @@ pub enum DataError {
#[fail(display = "Hyper Error: {}", _0)] #[fail(display = "Hyper Error: {}", _0)]
HyperError(#[cause] hyper::Error), HyperError(#[cause] hyper::Error),
#[fail(display = "Failed to parse a url: {}", _0)] #[fail(display = "Failed to parse a url: {}", _0)]
// TODO: print the url too
UrlError(#[cause] url::ParseError), UrlError(#[cause] url::ParseError),
#[fail(display = "TLS Error: {}", _0)] #[fail(display = "TLS Error: {}", _0)]
TLSError(#[cause] native_tls::Error), TLSError(#[cause] native_tls::Error),
@@ -62,10 +58,7 @@ pub enum DataError {
HttpStatusGeneral(HttpStatusError), HttpStatusGeneral(HttpStatusError),
#[fail(display = "FIXME: This should be better")] #[fail(display = "FIXME: This should be better")]
F301(Source), F301(Source),
#[fail( #[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
display = "Error occured while Parsing an Episode. Reason: {}",
reason
)]
ParseEpisodeError { reason: String, parent_id: i32 }, ParseEpisodeError { reason: String, parent_id: i32 },
#[fail(display = "Episode was not changed and thus skipped.")] #[fail(display = "Episode was not changed and thus skipped.")]
EpisodeNotChanged, EpisodeNotChanged,
@@ -9,7 +9,7 @@ use rss;
use dbqueries; use dbqueries;
use errors::DataError; use errors::DataError;
use models::{Index, IndexState, Update}; use models::{Index, IndexState, Update};
use models::{NewEpisode, NewEpisodeMinimal, NewShow, Show}; use models::{NewEpisode, NewEpisodeMinimal, NewPodcast, Podcast};
/// Wrapper struct that hold a `Source` id and the `rss::Channel` /// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field. /// that corresponds to the `Source.uri` field.
@@ -31,15 +31,15 @@ impl Feed {
.and_then(move |pd| self.index_channel_items(pd)) .and_then(move |pd| self.index_channel_items(pd))
} }
fn parse_podcast(&self) -> NewShow { fn parse_podcast(&self) -> NewPodcast {
NewShow::new(&self.channel, self.source_id) NewPodcast::new(&self.channel, self.source_id)
} }
fn parse_podcast_async(&self) -> impl Future<Item = NewShow, Error = DataError> + Send { fn parse_podcast_async(&self) -> impl Future<Item = NewPodcast, Error = DataError> + Send {
ok(self.parse_podcast()) ok(self.parse_podcast())
} }
fn index_channel_items(self, pd: Show) -> impl Future<Item = (), Error = DataError> + Send { fn index_channel_items(self, pd: Podcast) -> impl Future<Item = (), Error = DataError> + Send {
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items()); let stream = stream::iter_ok::<_, DataError>(self.channel.into_items());
// Parse the episodes // Parse the episodes
@@ -65,12 +65,12 @@ fn determine_ep_state(
item: &rss::Item, item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> { ) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists // Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.show_id())?; let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
if !exists { if !exists {
Ok(IndexState::Index(ep.into_new_episode(item))) Ok(IndexState::Index(ep.into_new_episode(item)))
} else { } else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.show_id())?; let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
let rowid = old.rowid(); let rowid = old.rowid();
if ep != old { if ep != old {
@@ -87,22 +87,21 @@ fn filter_episodes<'a, S>(
where where
S: Stream<Item = IndexState<NewEpisode>, Error = DataError> + Send + 'a, S: Stream<Item = IndexState<NewEpisode>, Error = DataError> + Send + 'a,
{ {
stream stream.filter_map(|state| match state {
.filter_map(|state| match state { IndexState::NotChanged => None,
IndexState::NotChanged => None, // Update individual rows, and filter them
// Update individual rows, and filter them IndexState::Update((ref ep, rowid)) => {
IndexState::Update((ref ep, rowid)) => { ep.update(rowid)
ep.update(rowid) .map_err(|err| error!("{}", err))
.map_err(|err| error!("{}", err)) .map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title())) .ok();
.ok();
None None
} },
IndexState::Index(s) => Some(s), IndexState::Index(s) => Some(s),
}) })
// only Index is left, collect them for batch index // only Index is left, collect them for batch index
.collect() .collect()
} }
fn batch_insert_episodes(episodes: &[NewEpisode]) { fn batch_insert_episodes(episodes: &[NewEpisode]) {
@@ -115,7 +114,8 @@ fn batch_insert_episodes(episodes: &[NewEpisode]) {
.map_err(|err| { .map_err(|err| {
error!("Failed batch indexng: {}", err); error!("Failed batch indexng: {}", err);
info!("Fallign back to individual indexing."); info!("Fallign back to individual indexing.");
}).unwrap_or_else(|_| { })
.unwrap_or_else(|_| {
episodes.iter().for_each(|ep| { episodes.iter().for_each(|ep| {
ep.index() ep.index()
.map_err(|err| error!("Error: {}.", err)) .map_err(|err| error!("Error: {}.", err))
@@ -127,7 +127,6 @@ fn batch_insert_episodes(episodes: &[NewEpisode]) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use failure::Error;
use rss::Channel; use rss::Channel;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
@@ -172,8 +171,8 @@ mod tests {
}; };
#[test] #[test]
fn test_complete_index() -> Result<(), Error> { fn test_complete_index() {
truncate_db()?; truncate_db().unwrap();
let feeds: Vec<_> = URLS let feeds: Vec<_> = URLS
.iter() .iter()
@@ -181,46 +180,44 @@ mod tests {
// Create and insert a Source into db // Create and insert a Source into db
let s = Source::from_url(url).unwrap(); let s = Source::from_url(url).unwrap();
get_feed(path, s.id()) get_feed(path, s.id())
}).collect(); })
.collect();
let mut core = Core::new()?; let mut core = Core::new().unwrap();
// Index the channes // Index the channels
let list: Vec<_> = feeds.into_iter().map(|x| x.index()).collect(); let list: Vec<_> = feeds.into_iter().map(|x| x.index()).collect();
let _foo = core.run(join_all(list)); let _foo = core.run(join_all(list));
// Assert the index rows equal the controlled results // Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources()?.len(), 5); assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts()?.len(), 5); assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes()?.len(), 354); assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
Ok(())
} }
#[test] #[test]
fn test_feed_parse_podcast() -> Result<(), Error> { fn test_feed_parse_podcast() {
truncate_db()?; truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml"; let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42); let feed = get_feed(path, 42);
let file = fs::File::open(path)?; let file = fs::File::open(path).unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(feed.parse_podcast(), pd); assert_eq!(feed.parse_podcast(), pd);
Ok(())
} }
#[test] #[test]
fn test_feed_index_channel_items() -> Result<(), Error> { fn test_feed_index_channel_items() {
truncate_db()?; truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml"; let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42); let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast()?; let pd = feed.parse_podcast().to_podcast().unwrap();
feed.index_channel_items(pd).wait()?; feed.index_channel_items(pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts()?.len(), 1); assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
assert_eq!(dbqueries::get_episodes()?.len(), 43); assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
Ok(())
} }
} }
+117
View File
@@ -0,0 +1,117 @@
#![recursion_limit = "1024"]
#![allow(unknown_lints)]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
#![cfg_attr(feature = "cargo-clippy", allow(option_map_unit_fn))]
#![cfg_attr(
feature = "clippy",
warn(
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut,
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else,
items_after_statements, used_underscore_binding
)
)]
#![warn(
bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals, path_statements,
patterns_in_fns_without_body, plugin_as_library, private_in_public, private_no_mangle_fns,
private_no_mangle_statics, safe_extern_statics, unconditional_recursion,
unions_with_drop_fields, unused_allocation, unused_comparisons, unused_parens, while_true,
missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts,
unused_extern_crates, unused
)]
#![deny(warnings)]
//! FIXME: Docs
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
#[cfg(test)]
#[macro_use]
extern crate maplit;
#[macro_use]
extern crate derive_builder;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
// #[macro_use]
extern crate failure;
#[macro_use]
extern crate failure_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
extern crate ammonia;
extern crate chrono;
extern crate futures;
extern crate hyper;
extern crate hyper_tls;
extern crate 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;
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::Save;
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
// Set the user agent, See #53 for more
// Keep this in sync with Tor-browser releases
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0";
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]
pub mod xdg_dirs {
use std::path::PathBuf;
use xdg;
lazy_static!{
pub(crate) static ref HAMMOND_XDG: xdg::BaseDirectories = {
xdg::BaseDirectories::with_prefix("hammond").unwrap()
};
/// XDG_DATA Directory `Pathbuf`.
pub static ref HAMMOND_DATA: PathBuf = {
HAMMOND_XDG.create_data_directory(HAMMOND_XDG.get_data_home()).unwrap()
};
/// XDG_CONFIG Directory `Pathbuf`.
pub static ref HAMMOND_CONFIG: PathBuf = {
HAMMOND_XDG.create_config_directory(HAMMOND_XDG.get_config_home()).unwrap()
};
/// XDG_CACHE Directory `Pathbuf`.
pub static ref HAMMOND_CACHE: PathBuf = {
HAMMOND_XDG.create_cache_directory(HAMMOND_XDG.get_cache_home()).unwrap()
};
/// Hammond Download Direcotry `PathBuf`.
pub static ref DL_DIR: PathBuf = {
HAMMOND_XDG.create_data_directory("Downloads").unwrap()
};
}
}
@@ -5,14 +5,14 @@ use diesel::SaveChangesDsl;
use database::connection; use database::connection;
use errors::DataError; use errors::DataError;
use models::{Save, Show}; use models::{Podcast, Save};
use schema::episodes; use schema::episode;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)] #[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[table_name = "episodes"] #[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, show_id)] #[primary_key(title, podcast_id)]
#[belongs_to(Show, foreign_key = "show_id")] #[belongs_to(Podcast, foreign_key = "podcast_id")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Diesel Model of the episode table. /// Diesel Model of the episode table.
pub struct Episode { pub struct Episode {
@@ -26,7 +26,9 @@ pub struct Episode {
duration: Option<i32>, duration: Option<i32>,
guid: Option<String>, guid: Option<String>,
played: Option<i32>, played: Option<i32>,
show_id: i32, favorite: bool,
archive: bool,
podcast_id: i32,
} }
impl Save<Episode> for Episode { impl Save<Episode> for Episode {
@@ -53,6 +55,11 @@ impl Episode {
&self.title &self.title
} }
/// Set the `title`.
pub fn set_title(&mut self, value: &str) {
self.title = value.to_string();
}
/// Get the value of the `uri`. /// Get the value of the `uri`.
/// ///
/// Represents the url(usually) that the media file will be located at. /// Represents the url(usually) that the media file will be located at.
@@ -60,6 +67,11 @@ impl Episode {
self.uri.as_ref().map(|s| s.as_str()) self.uri.as_ref().map(|s| s.as_str())
} }
/// Set the `uri`.
pub fn set_uri(&mut self, value: Option<&str>) {
self.uri = value.map(|x| x.to_string());
}
/// Get the value of the `local_uri`. /// Get the value of the `local_uri`.
/// ///
/// Represents the local uri,usually filesystem path, /// Represents the local uri,usually filesystem path,
@@ -68,16 +80,31 @@ impl Episode {
self.local_uri.as_ref().map(|s| s.as_str()) self.local_uri.as_ref().map(|s| s.as_str())
} }
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Get the `description`. /// Get the `description`.
pub fn description(&self) -> Option<&str> { pub fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str()) self.description.as_ref().map(|s| s.as_str())
} }
/// Set the `description`.
pub fn set_description(&mut self, value: Option<&str>) {
self.description = value.map(|x| x.to_string());
}
/// Get the Episode's `guid`. /// Get the Episode's `guid`.
pub fn guid(&self) -> Option<&str> { pub fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str()) self.guid.as_ref().map(|s| s.as_str())
} }
/// Set the `guid`.
pub fn set_guid(&mut self, value: Option<&str>) {
self.guid = value.map(|x| x.to_string());
}
/// Get the `epoch` value. /// Get the `epoch` value.
/// ///
/// Retrieved from the rss Item publish date. /// Retrieved from the rss Item publish date.
@@ -86,6 +113,11 @@ impl Episode {
self.epoch self.epoch
} }
/// Set the `epoch`.
pub fn set_epoch(&mut self, value: i32) {
self.epoch = value;
}
/// Get the `length`. /// Get the `length`.
/// ///
/// The number represents the size of the file in bytes. /// The number represents the size of the file in bytes.
@@ -93,6 +125,11 @@ impl Episode {
self.length self.length
} }
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Get the `duration` value. /// Get the `duration` value.
/// ///
/// The number represents the duration of the item/episode in seconds. /// The number represents the duration of the item/episode in seconds.
@@ -100,6 +137,11 @@ impl Episode {
self.duration self.duration
} }
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played. /// Epoch representation of the last time the episode was played.
/// ///
/// None/Null for unplayed. /// None/Null for unplayed.
@@ -107,19 +149,54 @@ impl Episode {
self.played self.played
} }
/// `Show` table foreign key. /// Set the `played` value.
pub fn show_id(&self) -> i32 { pub fn set_played(&mut self, value: Option<i32>) {
self.show_id self.played = value;
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
///
/// If true, the download cleanr will ignore the episode
/// and the corresponding media value will never be automaticly deleted.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Episode`.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
pub fn set_played_now(&mut self) -> Result<(), DataError> {
let epoch = Utc::now().timestamp() as i32;
self.set_played(Some(epoch));
self.save().map(|_| ())
} }
} }
#[derive(Queryable, AsChangeset, PartialEq)] #[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episodes"] #[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, show_id)] #[primary_key(title, podcast_id)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Diesel Model to be used for constructing `EpisodeWidgets`. /// Diesel Model to be used for constructing `EpisodeWidgets`.
pub struct EpisodeWidgetModel { pub struct EpisodeWidgetQuery {
rowid: i32, rowid: i32,
title: String, title: String,
uri: Option<String>, uri: Option<String>,
@@ -128,12 +205,14 @@ pub struct EpisodeWidgetModel {
length: Option<i32>, length: Option<i32>,
duration: Option<i32>, duration: Option<i32>,
played: Option<i32>, played: Option<i32>,
show_id: i32, // favorite: bool,
// archive: bool,
podcast_id: i32,
} }
impl From<Episode> for EpisodeWidgetModel { impl From<Episode> for EpisodeWidgetQuery {
fn from(e: Episode) -> EpisodeWidgetModel { fn from(e: Episode) -> EpisodeWidgetQuery {
EpisodeWidgetModel { EpisodeWidgetQuery {
rowid: e.rowid, rowid: e.rowid,
title: e.title, title: e.title,
uri: e.uri, uri: e.uri,
@@ -142,30 +221,30 @@ impl From<Episode> for EpisodeWidgetModel {
length: e.length, length: e.length,
duration: e.duration, duration: e.duration,
played: e.played, played: e.played,
show_id: e.show_id, podcast_id: e.podcast_id,
} }
} }
} }
impl Save<usize> for EpisodeWidgetModel { impl Save<usize> for EpisodeWidgetQuery {
type Error = DataError; type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the /// Helper method to easily save/"sync" current state of self to the
/// Database. /// Database.
fn save(&self) -> Result<usize, Self::Error> { fn save(&self) -> Result<usize, Self::Error> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let tempdb = db.get()?; let tempdb = db.get()?;
diesel::update(episodes.filter(rowid.eq(self.rowid))) diesel::update(episode.filter(rowid.eq(self.rowid)))
.set(self) .set(self)
.execute(&*tempdb) .execute(&*tempdb)
.map_err(From::from) .map_err(From::from)
} }
} }
impl EpisodeWidgetModel { impl EpisodeWidgetQuery {
/// Get the value of the sqlite's `ROW_ID` /// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 { pub fn rowid(&self) -> i32 {
self.rowid self.rowid
@@ -223,6 +302,11 @@ impl EpisodeWidgetModel {
self.duration self.duration
} }
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played. /// Epoch representation of the last time the episode was played.
/// ///
/// None/Null for unplayed. /// None/Null for unplayed.
@@ -231,13 +315,36 @@ impl EpisodeWidgetModel {
} }
/// Set the `played` value. /// Set the `played` value.
fn set_played(&mut self, value: Option<i32>) { pub fn set_played(&mut self, value: Option<i32>) {
self.played = value; self.played = value;
} }
/// `Show` table foreign key. // /// Represents the archiving policy for the episode.
pub fn show_id(&self) -> i32 { // pub fn archive(&self) -> bool {
self.show_id // self.archive
// }
// /// Set the `archive` policy.
// ///
// /// If true, the download cleanr will ignore the episode
// /// and the corresponding media value will never be automaticly deleted.
// pub fn set_archive(&mut self, b: bool) {
// self.archive = b
// }
// /// Get the `favorite` status of the `Episode`.
// pub fn favorite(&self) -> bool {
// self.favorite
// }
// /// Set `favorite` status.
// pub fn set_favorite(&mut self, b: bool) {
// self.favorite = b
// }
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
} }
/// Sets the `played` value with the current `epoch` timestap and save it. /// Sets the `played` value with the current `epoch` timestap and save it.
@@ -249,38 +356,38 @@ impl EpisodeWidgetModel {
} }
#[derive(Queryable, AsChangeset, PartialEq)] #[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episodes"] #[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, show_id)] #[primary_key(title, podcast_id)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Diesel Model to be used internal with the `utils::checkup` function. /// Diesel Model to be used internal with the `utils::checkup` function.
pub struct EpisodeCleanerModel { pub struct EpisodeCleanerQuery {
rowid: i32, rowid: i32,
local_uri: Option<String>, local_uri: Option<String>,
played: Option<i32>, played: Option<i32>,
} }
impl Save<usize> for EpisodeCleanerModel { impl Save<usize> for EpisodeCleanerQuery {
type Error = DataError; type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the /// Helper method to easily save/"sync" current state of self to the
/// Database. /// Database.
fn save(&self) -> Result<usize, Self::Error> { fn save(&self) -> Result<usize, Self::Error> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let tempdb = db.get()?; let tempdb = db.get()?;
diesel::update(episodes.filter(rowid.eq(self.rowid))) diesel::update(episode.filter(rowid.eq(self.rowid)))
.set(self) .set(self)
.execute(&*tempdb) .execute(&*tempdb)
.map_err(From::from) .map_err(From::from)
} }
} }
impl From<Episode> for EpisodeCleanerModel { impl From<Episode> for EpisodeCleanerQuery {
fn from(e: Episode) -> EpisodeCleanerModel { fn from(e: Episode) -> EpisodeCleanerQuery {
EpisodeCleanerModel { EpisodeCleanerQuery {
rowid: e.rowid(), rowid: e.rowid(),
local_uri: e.local_uri, local_uri: e.local_uri,
played: e.played, played: e.played,
@@ -288,7 +395,7 @@ impl From<Episode> for EpisodeCleanerModel {
} }
} }
impl EpisodeCleanerModel { impl EpisodeCleanerQuery {
/// Get the value of the sqlite's `ROW_ID` /// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 { pub fn rowid(&self) -> i32 {
self.rowid self.rowid
@@ -321,9 +428,9 @@ impl EpisodeCleanerModel {
} }
#[derive(Queryable, AsChangeset, PartialEq)] #[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episodes"] #[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")] #[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, show_id)] #[primary_key(title, podcast_id)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// Diesel Model to be used for FIXME. /// Diesel Model to be used for FIXME.
pub struct EpisodeMinimal { pub struct EpisodeMinimal {
@@ -331,10 +438,9 @@ pub struct EpisodeMinimal {
title: String, title: String,
uri: Option<String>, uri: Option<String>,
epoch: i32, epoch: i32,
length: Option<i32>,
duration: Option<i32>, duration: Option<i32>,
guid: Option<String>, guid: Option<String>,
show_id: i32, podcast_id: i32,
} }
impl From<Episode> for EpisodeMinimal { impl From<Episode> for EpisodeMinimal {
@@ -343,11 +449,10 @@ impl From<Episode> for EpisodeMinimal {
rowid: e.rowid, rowid: e.rowid,
title: e.title, title: e.title,
uri: e.uri, uri: e.uri,
length: e.length,
guid: e.guid, guid: e.guid,
epoch: e.epoch, epoch: e.epoch,
duration: e.duration, duration: e.duration,
show_id: e.show_id, podcast_id: e.podcast_id,
} }
} }
} }
@@ -383,13 +488,6 @@ impl EpisodeMinimal {
self.epoch self.epoch
} }
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Get the `duration` value. /// Get the `duration` value.
/// ///
/// The number represents the duration of the item/episode in seconds. /// The number represents the duration of the item/episode in seconds.
@@ -397,8 +495,8 @@ impl EpisodeMinimal {
self.duration self.duration
} }
/// `Show` table foreign key. /// `Podcast` table foreign key.
pub fn show_id(&self) -> i32 { pub fn podcast_id(&self) -> i32 {
self.show_id self.podcast_id
} }
} }
@@ -1,26 +1,26 @@
mod new_episode; mod new_episode;
mod new_show; mod new_podcast;
mod new_source; mod new_source;
mod episode; mod episode;
mod show; mod podcast;
mod source; mod source;
// use futures::prelude::*; // use futures::prelude::*;
// use futures::future::*; // use futures::future::*;
pub(crate) use self::episode::EpisodeCleanerModel; pub(crate) use self::episode::EpisodeCleanerQuery;
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal}; pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
pub(crate) use self::new_show::NewShow; pub(crate) use self::new_podcast::NewPodcast;
pub(crate) use self::new_source::NewSource; pub(crate) use self::new_source::NewSource;
#[cfg(test)] #[cfg(test)]
pub(crate) use self::new_episode::NewEpisodeBuilder; pub(crate) use self::new_episode::NewEpisodeBuilder;
#[cfg(test)] #[cfg(test)]
pub(crate) use self::new_show::NewShowBuilder; pub(crate) use self::new_podcast::NewPodcastBuilder;
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetModel}; pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetQuery};
pub use self::show::{Show, ShowCoverModel}; pub use self::podcast::{Podcast, PodcastCoverQuery};
pub use self::source::Source; pub use self::source::Source;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -9,11 +9,11 @@ use dbqueries;
use errors::DataError; use errors::DataError;
use models::{Episode, EpisodeMinimal, Index, Insert, Update}; use models::{Episode, EpisodeMinimal, Index, Insert, Update};
use parser; use parser;
use schema::episodes; use schema::episode;
use utils::url_cleaner; use utils::url_cleaner;
#[derive(Insertable, AsChangeset)] #[derive(Insertable, AsChangeset)]
#[table_name = "episodes"] #[table_name = "episode"]
#[derive(Debug, Clone, Default, Builder, PartialEq)] #[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)] #[builder(default)]
#[builder(derive(Debug))] #[builder(derive(Debug))]
@@ -26,7 +26,7 @@ pub(crate) struct NewEpisode {
duration: Option<i32>, duration: Option<i32>,
guid: Option<String>, guid: Option<String>,
epoch: i32, epoch: i32,
show_id: i32, podcast_id: i32,
} }
impl From<NewEpisodeMinimal> for NewEpisode { impl From<NewEpisodeMinimal> for NewEpisode {
@@ -36,7 +36,7 @@ impl From<NewEpisodeMinimal> for NewEpisode {
.uri(e.uri) .uri(e.uri)
.duration(e.duration) .duration(e.duration)
.epoch(e.epoch) .epoch(e.epoch)
.show_id(e.show_id) .podcast_id(e.podcast_id)
.guid(e.guid) .guid(e.guid)
.build() .build()
.unwrap() .unwrap()
@@ -47,12 +47,12 @@ impl Insert<()> for NewEpisode {
type Error = DataError; type Error = DataError;
fn insert(&self) -> Result<(), DataError> { fn insert(&self) -> Result<(), DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
info!("Inserting {:?}", self.title); info!("Inserting {:?}", self.title);
diesel::insert_into(episodes) diesel::insert_into(episode)
.values(self) .values(self)
.execute(&con) .execute(&con)
.map_err(From::from) .map_err(From::from)
@@ -64,12 +64,12 @@ impl Update<()> for NewEpisode {
type Error = DataError; type Error = DataError;
fn update(&self, episode_id: i32) -> Result<(), DataError> { fn update(&self, episode_id: i32) -> Result<(), DataError> {
use schema::episodes::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
info!("Updating {:?}", self.title); info!("Updating {:?}", self.title);
diesel::update(episodes.filter(rowid.eq(episode_id))) diesel::update(episode.filter(rowid.eq(episode_id)))
.set(self) .set(self)
.execute(&con) .execute(&con)
.map_err(From::from) .map_err(From::from)
@@ -83,10 +83,10 @@ impl Index<()> for NewEpisode {
// Does not update the episode description if it's the only thing that has // Does not update the episode description if it's the only thing that has
// changed. // changed.
fn index(&self) -> Result<(), DataError> { fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::episode_exists(self.title(), self.show_id())?; let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
if exists { if exists {
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.show_id())?; let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.podcast_id())?;
if self != &other { if self != &other {
self.update(other.rowid()) self.update(other.rowid())
@@ -106,7 +106,7 @@ impl PartialEq<EpisodeMinimal> for NewEpisode {
&& (self.duration() == other.duration()) && (self.duration() == other.duration())
&& (self.epoch() == other.epoch()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.guid() == other.guid())
&& (self.show_id() == other.show_id()) && (self.podcast_id() == other.podcast_id())
} }
} }
@@ -117,7 +117,7 @@ impl PartialEq<Episode> for NewEpisode {
&& (self.duration() == other.duration()) && (self.duration() == other.duration())
&& (self.epoch() == other.epoch()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.guid() == other.guid())
&& (self.show_id() == other.show_id()) && (self.podcast_id() == other.podcast_id())
&& (self.description() == other.description()) && (self.description() == other.description())
&& (self.length() == other.length()) && (self.length() == other.length())
} }
@@ -126,14 +126,14 @@ impl PartialEq<Episode> for NewEpisode {
impl NewEpisode { impl NewEpisode {
/// Parses an `rss::Item` into a `NewEpisode` Struct. /// Parses an `rss::Item` into a `NewEpisode` Struct.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn new(item: &rss::Item, show_id: i32) -> Result<Self, DataError> { pub(crate) fn new(item: &rss::Item, podcast_id: i32) -> Result<Self, DataError> {
NewEpisodeMinimal::new(item, show_id).map(|ep| ep.into_new_episode(item)) NewEpisodeMinimal::new(item, podcast_id).map(|ep| ep.into_new_episode(item))
} }
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn to_episode(&self) -> Result<Episode, DataError> { pub(crate) fn to_episode(&self) -> Result<Episode, DataError> {
self.index()?; self.index()?;
dbqueries::get_episode_from_pk(&self.title, self.show_id).map_err(From::from) dbqueries::get_episode_from_pk(&self.title, self.podcast_id).map_err(From::from)
} }
} }
@@ -167,24 +167,23 @@ impl NewEpisode {
self.length self.length
} }
pub(crate) fn show_id(&self) -> i32 { pub(crate) fn podcast_id(&self) -> i32 {
self.show_id self.podcast_id
} }
} }
#[derive(Insertable, AsChangeset)] #[derive(Insertable, AsChangeset)]
#[table_name = "episodes"] #[table_name = "episode"]
#[derive(Debug, Clone, Builder, PartialEq)] #[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))] #[builder(derive(Debug))]
#[builder(setter(into))] #[builder(setter(into))]
pub(crate) struct NewEpisodeMinimal { pub(crate) struct NewEpisodeMinimal {
title: String, title: String,
uri: Option<String>, uri: Option<String>,
length: Option<i32>,
duration: Option<i32>, duration: Option<i32>,
epoch: i32, epoch: i32,
guid: Option<String>, guid: Option<String>,
show_id: i32, podcast_id: i32,
} }
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal { impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
@@ -194,7 +193,7 @@ impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
&& (self.duration() == other.duration()) && (self.duration() == other.duration())
&& (self.epoch() == other.epoch()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.guid() == other.guid())
&& (self.show_id() == other.show_id()) && (self.podcast_id() == other.podcast_id())
} }
} }
@@ -212,20 +211,12 @@ impl NewEpisodeMinimal {
let title = item.title().unwrap().trim().to_owned(); let title = item.title().unwrap().trim().to_owned();
let guid = item.guid().map(|s| s.value().trim().to_owned()); let guid = item.guid().map(|s| s.value().trim().to_owned());
// Get the mime type, the `http` url and the length from the enclosure let uri = item.enclosure()
// http://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt
let enc = item.enclosure();
// Get the url
let uri = enc
.map(|s| url_cleaner(s.url().trim())) .map(|s| url_cleaner(s.url().trim()))
// Fallback to Rss.Item.link if enclosure is None. // Fallback to Rss.Item.link if enclosure is None.
.or_else(|| item.link().map(|s| url_cleaner(s.trim()))); .or_else(|| item.link().map(|s| url_cleaner(s.trim())));
// Get the size of the content, it should be in bytes // If url is still None return an Error as this behaviour is
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. // compliant with the RSS Spec.
if uri.is_none() { if uri.is_none() {
let err = DataError::ParseEpisodeError { let err = DataError::ParseEpisodeError {
@@ -247,18 +238,17 @@ impl NewEpisodeMinimal {
NewEpisodeMinimalBuilder::default() NewEpisodeMinimalBuilder::default()
.title(title) .title(title)
.uri(uri) .uri(uri)
.length(length)
.duration(duration) .duration(duration)
.epoch(epoch) .epoch(epoch)
.guid(guid) .guid(guid)
.show_id(parent_id) .podcast_id(parent_id)
.build() .build()
.map_err(From::from) .map_err(From::from)
} }
// TODO: TryInto is stabilizing in rustc v1.26! // TODO: TryInto is stabilizing in rustc v1.26!
// ^ Jokes on you past self!
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode { 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().and_then(|s| { let description = item.description().and_then(|s| {
let sanitized_html = ammonia::Builder::new() let sanitized_html = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags // Remove `rel` attributes from `<a>` tags
@@ -273,9 +263,9 @@ impl NewEpisodeMinimal {
.uri(self.uri) .uri(self.uri)
.duration(self.duration) .duration(self.duration)
.epoch(self.epoch) .epoch(self.epoch)
.show_id(self.show_id) .podcast_id(self.podcast_id)
.guid(self.guid) .guid(self.guid)
.length(self.length) .length(length)
.description(description) .description(description)
.build() .build()
.unwrap() .unwrap()
@@ -304,16 +294,14 @@ impl NewEpisodeMinimal {
self.epoch self.epoch
} }
pub(crate) fn show_id(&self) -> i32 { pub(crate) fn podcast_id(&self) -> i32 {
self.show_id self.podcast_id
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use database::truncate_db; use database::truncate_db;
use dbqueries; use dbqueries;
use failure::Error;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder}; use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use models::*; use models::*;
@@ -332,11 +320,11 @@ mod tests {
.title("The Super Bowl of Racism") .title("The Super Bowl of Racism")
.uri(Some(String::from( .uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3", "http://traffic.megaphone.fm/PPY6458293736.mp3",
))).guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24"))) )))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.epoch(1505296800) .epoch(1505296800)
.length(Some(66738886))
.duration(Some(4171)) .duration(Some(4171))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -345,11 +333,11 @@ mod tests {
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America") .title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
.uri(Some(String::from( .uri(Some(String::from(
"http://traffic.megaphone.fm/FL5331443769.mp3", "http://traffic.megaphone.fm/FL5331443769.mp3",
))).guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d"))) )))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.epoch(1502272800) .epoch(1502272800)
.length(Some(67527575))
.duration(Some(4415)) .duration(Some(4415))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -364,12 +352,13 @@ mod tests {
.title("The Super Bowl of Racism") .title("The Super Bowl of Racism")
.uri(Some(String::from( .uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3", "http://traffic.megaphone.fm/PPY6458293736.mp3",
))).description(Some(String::from(descr))) )))
.description(Some(String::from(descr)))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24"))) .guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.length(Some(66738886)) .length(Some(66738886))
.epoch(1505296800) .epoch(1505296800)
.duration(Some(4171)) .duration(Some(4171))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -387,12 +376,13 @@ mod tests {
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America") .title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
.uri(Some(String::from( .uri(Some(String::from(
"http://traffic.megaphone.fm/FL5331443769.mp3", "http://traffic.megaphone.fm/FL5331443769.mp3",
))).description(Some(String::from(descr))) )))
.description(Some(String::from(descr)))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d"))) .guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.length(Some(67527575)) .length(Some(67527575))
.epoch(1502272800) .epoch(1502272800)
.duration(Some(4415)) .duration(Some(4415))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -401,12 +391,13 @@ mod tests {
.title("The Super Bowl of Racism") .title("The Super Bowl of Racism")
.uri(Some(String::from( .uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3", "http://traffic.megaphone.fm/PPY6458293736.mp3",
))).description(Some(String::from("New description"))) )))
.description(Some(String::from("New description")))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24"))) .guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.length(Some(66738886)) .length(Some(66738886))
.epoch(1505296800) .epoch(1505296800)
.duration(Some(424242)) .duration(Some(424242))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -415,11 +406,11 @@ mod tests {
.title("Hacking Devices with Kali Linux | LUP 214") .title("Hacking Devices with Kali Linux | LUP 214")
.uri(Some(String::from( .uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3", "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)) .guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.epoch(1505280282) .epoch(1505280282)
.duration(Some(5733)) .duration(Some(5733))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -428,11 +419,11 @@ mod tests {
.title("Gnome Does it Again | LUP 213") .title("Gnome Does it Again | LUP 213")
.uri(Some(String::from( .uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3", "http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3",
))).guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B"))) )))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.epoch(1504670247) .epoch(1504670247)
.length(Some(36544272))
.duration(Some(4491)) .duration(Some(4491))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -446,12 +437,13 @@ mod tests {
.title("Hacking Devices with Kali Linux | LUP 214") .title("Hacking Devices with Kali Linux | LUP 214")
.uri(Some(String::from( .uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3", "http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
))).description(Some(String::from(descr))) )))
.description(Some(String::from(descr)))
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D"))) .guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.length(Some(46479789)) .length(Some(46479789))
.epoch(1505280282) .epoch(1505280282)
.duration(Some(5733)) .duration(Some(5733))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
@@ -467,93 +459,86 @@ mod tests {
.title("Gnome Does it Again | LUP 213") .title("Gnome Does it Again | LUP 213")
.uri(Some(String::from( .uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3", "http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3",
))).description(Some(String::from(descr))) )))
.description(Some(String::from(descr)))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B"))) .guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.length(Some(36544272)) .length(Some(36544272))
.epoch(1504670247) .epoch(1504670247)
.duration(Some(4491)) .duration(Some(4491))
.show_id(42) .podcast_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
} }
#[test] #[test]
fn test_new_episode_minimal_intercepted() -> Result<(), Error> { fn test_new_episode_minimal_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap(); let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?; let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_1); assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap(); let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?; let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_2); assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_2);
Ok(())
} }
#[test] #[test]
fn test_new_episode_intercepted() -> Result<(), Error> { fn test_new_episode_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap(); let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisode::new(&episode, 42)?; let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_INTERCEPTED_1); assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap(); let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisode::new(&episode, 42)?; let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_INTERCEPTED_2); assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
Ok(())
} }
#[test] #[test]
fn test_new_episode_minimal_lup() -> Result<(), Error> { fn test_new_episode_minimal_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?; let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(18).unwrap(); let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?; let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_1); assert_eq!(ep, *EXPECTED_MINIMAL_LUP_1);
let episode = channel.items().iter().nth(19).unwrap(); let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42)?; let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_2); assert_eq!(ep, *EXPECTED_MINIMAL_LUP_2);
Ok(())
} }
#[test] #[test]
fn test_new_episode_lup() -> Result<(), Error> { fn test_new_episode_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?; let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(18).unwrap(); let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisode::new(&episode, 42)?; let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_LUP_1); assert_eq!(ep, *EXPECTED_LUP_1);
let episode = channel.items().iter().nth(19).unwrap(); let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisode::new(&episode, 42)?; let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_LUP_2); assert_eq!(ep, *EXPECTED_LUP_2);
Ok(())
} }
#[test] #[test]
fn test_minimal_into_new_episode() -> Result<(), Error> { fn test_minimal_into_new_episode() {
truncate_db()?; truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let item = channel.items().iter().nth(14).unwrap(); let item = channel.items().iter().nth(14).unwrap();
let ep = EXPECTED_MINIMAL_INTERCEPTED_1 let ep = EXPECTED_MINIMAL_INTERCEPTED_1
.clone() .clone()
.into_new_episode(&item); .into_new_episode(&item);
println!(
"EPISODE: {:#?}\nEXPECTED: {:#?}",
ep, *EXPECTED_INTERCEPTED_1
);
assert_eq!(ep, *EXPECTED_INTERCEPTED_1); assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let item = channel.items().iter().nth(15).unwrap(); let item = channel.items().iter().nth(15).unwrap();
@@ -561,58 +546,61 @@ mod tests {
.clone() .clone()
.into_new_episode(&item); .into_new_episode(&item);
assert_eq!(ep, *EXPECTED_INTERCEPTED_2); assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
Ok(())
} }
#[test] #[test]
fn test_new_episode_insert() -> Result<(), Error> { fn test_new_episode_insert() {
truncate_db()?; truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap(); let episode = channel.items().iter().nth(14).unwrap();
let new_ep = NewEpisode::new(&episode, 42)?; let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert()?; new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?; let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
assert_eq!(new_ep, ep); assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1); assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1);
assert_eq!(&*EXPECTED_INTERCEPTED_1, &ep); assert_eq!(&*EXPECTED_INTERCEPTED_1, &ep);
let episode = channel.items().iter().nth(15).unwrap(); let episode = channel.items().iter().nth(15).unwrap();
let new_ep = NewEpisode::new(&episode, 42)?; let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert()?; new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.show_id())?; let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
assert_eq!(new_ep, ep); assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2); assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2);
assert_eq!(&*EXPECTED_INTERCEPTED_2, &ep); assert_eq!(&*EXPECTED_INTERCEPTED_2, &ep);
Ok(())
} }
#[test] #[test]
fn test_new_episode_update() -> Result<(), Error> { fn test_new_episode_update() {
truncate_db()?; truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED_1.clone().to_episode()?; let old = EXPECTED_INTERCEPTED_1.clone().to_episode().unwrap();
let updated = &*UPDATED_DURATION_INTERCEPTED_1; let updated = &*UPDATED_DURATION_INTERCEPTED_1;
updated.update(old.rowid())?; updated.update(old.rowid()).unwrap();
let new = dbqueries::get_episode_from_pk(old.title(), old.show_id())?; let mut new = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
// Assert that updating does not change the rowid and show_id // Assert that updating does not change the rowid and podcast_id
assert_ne!(old, new); assert_ne!(old, new);
assert_eq!(old.rowid(), new.rowid()); assert_eq!(old.rowid(), new.rowid());
assert_eq!(old.show_id(), new.show_id()); assert_eq!(old.podcast_id(), new.podcast_id());
assert_eq!(updated, &new); assert_eq!(updated, &new);
assert_ne!(updated, &old); assert_ne!(updated, &old);
Ok(())
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] #[test]
fn test_new_episode_index() -> Result<(), Error> { fn test_new_episode_index() {
truncate_db()?; truncate_db().unwrap();
let expected = &*EXPECTED_INTERCEPTED_1; let expected = &*EXPECTED_INTERCEPTED_1;
// First insert // First insert
@@ -620,7 +608,7 @@ mod tests {
// Second identical, This should take the early return path // Second identical, This should take the early return path
assert!(expected.index().is_ok()); assert!(expected.index().is_ok());
// Get the episode // Get the episode
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?; let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
// Assert that NewPodcast is equal to the Indexed one // Assert that NewPodcast is equal to the Indexed one
assert_eq!(*expected, old); assert_eq!(*expected, old);
@@ -629,33 +617,42 @@ mod tests {
// Update the podcast // Update the podcast
assert!(updated.index().is_ok()); assert!(updated.index().is_ok());
// Get the new Podcast // Get the new Podcast
let new = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?; let new = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
// Assert it's diff from the old one. // Assert it's diff from the old one.
assert_ne!(new, old); assert_ne!(new, old);
assert_eq!(*updated, new); assert_eq!(*updated, new);
assert_eq!(new.rowid(), old.rowid()); assert_eq!(new.rowid(), old.rowid());
assert_eq!(new.show_id(), old.show_id()); assert_eq!(new.podcast_id(), old.podcast_id());
Ok(())
} }
#[test] #[test]
fn test_new_episode_to_episode() -> Result<(), Error> { fn test_new_episode_to_episode() {
let expected = &*EXPECTED_INTERCEPTED_1; let expected = &*EXPECTED_INTERCEPTED_1;
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
// Assert insert() produces the same result that you would get with to_podcast() // Assert insert() produces the same result that you would get with to_podcast()
truncate_db()?; truncate_db().unwrap();
expected.insert()?; expected.insert().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?; let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let ep = expected.to_episode()?; let ep = expected.to_episode().unwrap();
assert_eq!(old, ep); assert_eq!(old, ep);
// Same as above, diff order // Same as above, diff order
truncate_db()?; truncate_db().unwrap();
let ep = expected.to_episode()?; let ep = expected.to_episode().unwrap();
// This should error as a unique constrain violation // This should error as a unique constrain violation
assert!(expected.insert().is_err()); assert!(expected.insert().is_err());
let old = dbqueries::get_episode_from_pk(expected.title(), expected.show_id())?; let mut old =
dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
assert_eq!(old, ep); assert_eq!(old, ep);
Ok(())
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 rss;
use errors::DataError; use errors::DataError;
use models::Show; use models::Podcast;
use models::{Index, Insert, Update}; use models::{Index, Insert, Update};
use schema::shows; use schema::podcast;
use database::connection; use database::connection;
use dbqueries; use dbqueries;
use utils::url_cleaner; use utils::url_cleaner;
#[derive(Insertable, AsChangeset)] #[derive(Insertable, AsChangeset)]
#[table_name = "shows"] #[table_name = "podcast"]
#[derive(Debug, Clone, Default, Builder, PartialEq)] #[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)] #[builder(default)]
#[builder(derive(Debug))] #[builder(derive(Debug))]
#[builder(setter(into))] #[builder(setter(into))]
pub(crate) struct NewShow { pub(crate) struct NewPodcast {
title: String, title: String,
link: String, link: String,
description: String, description: String,
@@ -26,15 +26,15 @@ pub(crate) struct NewShow {
source_id: i32, source_id: i32,
} }
impl Insert<()> for NewShow { impl Insert<()> for NewPodcast {
type Error = DataError; type Error = DataError;
fn insert(&self) -> Result<(), Self::Error> { fn insert(&self) -> Result<(), Self::Error> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
diesel::insert_into(shows) diesel::insert_into(podcast)
.values(self) .values(self)
.execute(&con) .execute(&con)
.map(|_| ()) .map(|_| ())
@@ -42,16 +42,16 @@ impl Insert<()> for NewShow {
} }
} }
impl Update<()> for NewShow { impl Update<()> for NewPodcast {
type Error = DataError; type Error = DataError;
fn update(&self, show_id: i32) -> Result<(), Self::Error> { fn update(&self, podcast_id: i32) -> Result<(), Self::Error> {
use schema::shows::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
info!("Updating {}", self.title); info!("Updating {}", self.title);
diesel::update(shows.filter(id.eq(show_id))) diesel::update(podcast.filter(id.eq(podcast_id)))
.set(self) .set(self)
.execute(&con) .execute(&con)
.map(|_| ()) .map(|_| ())
@@ -61,7 +61,7 @@ impl Update<()> for NewShow {
// TODO: Maybe return an Enum<Action(Resut)> Instead. // TODO: Maybe return an Enum<Action(Resut)> Instead.
// It would make unti testing better too. // It would make unti testing better too.
impl Index<()> for NewShow { impl Index<()> for NewPodcast {
type Error = DataError; type Error = DataError;
fn index(&self) -> Result<(), DataError> { fn index(&self) -> Result<(), DataError> {
@@ -81,8 +81,8 @@ impl Index<()> for NewShow {
} }
} }
impl PartialEq<Show> for NewShow { impl PartialEq<Podcast> for NewPodcast {
fn eq(&self, other: &Show) -> bool { fn eq(&self, other: &Podcast) -> bool {
(self.link() == other.link()) (self.link() == other.link())
&& (self.title() == other.title()) && (self.title() == other.title())
&& (self.image_uri() == other.image_uri()) && (self.image_uri() == other.image_uri())
@@ -91,9 +91,9 @@ impl PartialEq<Show> for NewShow {
} }
} }
impl NewShow { impl NewPodcast {
/// Parses a `rss::Channel` into a `NewShow` Struct. /// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewShow { pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
let title = chan.title().trim(); let title = chan.title().trim();
let link = url_cleaner(chan.link().trim()); let link = url_cleaner(chan.link().trim());
@@ -111,7 +111,7 @@ impl NewShow {
// If itunes is None, try to get the channel.image from the rss spec // If itunes is None, try to get the channel.image from the rss spec
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned())); let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned()));
NewShowBuilder::default() NewPodcastBuilder::default()
.title(title) .title(title)
.description(description) .description(description)
.link(link) .link(link)
@@ -122,14 +122,14 @@ impl NewShow {
} }
// Look out for when tryinto lands into stable. // Look out for when tryinto lands into stable.
pub(crate) fn to_podcast(&self) -> Result<Show, DataError> { pub(crate) fn to_podcast(&self) -> Result<Podcast, DataError> {
self.index()?; self.index()?;
dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from) dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from)
} }
} }
// Ignore the following geters. They are used in unit tests mainly. // Ignore the following geters. They are used in unit tests mainly.
impl NewShow { impl NewPodcast {
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn source_id(&self) -> i32 { pub(crate) fn source_id(&self) -> i32 {
self.source_id self.source_id
@@ -157,18 +157,17 @@ mod tests {
use super::*; use super::*;
// use tokio_core::reactor::Core; // use tokio_core::reactor::Core;
use failure::Error;
use rss::Channel; use rss::Channel;
use database::truncate_db; use database::truncate_db;
use models::NewShowBuilder; use models::{NewPodcastBuilder, Save};
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::BufReader;
// Pre-built expected NewShow structs. // Pre-built expected NewPodcast structs.
lazy_static! { lazy_static! {
static ref EXPECTED_INTERCEPTED: NewShow = { static ref EXPECTED_INTERCEPTED: NewPodcast = {
let descr = "The people behind The Intercept’s fearless reporting and incisive \ let descr = "The people behind The Intercept’s fearless reporting and incisive \
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \ commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \
others—discuss the crucial issues of our time: national security, civil \ others—discuss the crucial issues of our time: national security, civil \
@@ -176,7 +175,7 @@ mod tests {
artists, thinkers, and newsmakers who challenge our preconceptions about \ artists, thinkers, and newsmakers who challenge our preconceptions about \
the world we live in."; the world we live in.";
NewShowBuilder::default() NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill") .title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts") .link("https://theintercept.com/podcasts")
.description(descr) .description(descr)
@@ -189,22 +188,23 @@ mod tests {
.build() .build()
.unwrap() .unwrap()
}; };
static ref EXPECTED_LUP: NewShow = { static ref EXPECTED_LUP: NewPodcast = {
let descr = "An open show powered by community LINUX Unplugged takes the best \ let descr = "An open show powered by community LINUX Unplugged takes the best \
attributes of open collaboration and focuses them into a weekly \ attributes of open collaboration and focuses them into a weekly \
lifestyle show about Linux."; lifestyle show about Linux.";
NewShowBuilder::default() NewPodcastBuilder::default()
.title("LINUX Unplugged Podcast") .title("LINUX Unplugged Podcast")
.link("http://www.jupiterbroadcasting.com/") .link("http://www.jupiterbroadcasting.com/")
.description(descr) .description(descr)
.image_uri(Some(String::from( .image_uri(Some(String::from(
"http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg", "http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg",
))).source_id(42) )))
.source_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
static ref EXPECTED_TIPOFF: NewShow = { static ref EXPECTED_TIPOFF: NewPodcast = {
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \ 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. \ scenes of some of the best investigative journalism from recent years. \
Each episode we’ll be digging into an investigative scoop- hearing from \ Each episode we’ll be digging into an investigative scoop- hearing from \
@@ -215,18 +215,19 @@ mod tests {
complicated detective work that goes into doing great investigative \ complicated detective work that goes into doing great investigative \
journalism- then this is the podcast for you.</p>"; journalism- then this is the podcast for you.</p>";
NewShowBuilder::default() NewPodcastBuilder::default()
.title("The Tip Off") .title("The Tip Off")
.link("http://www.acast.com/thetipoff") .link("http://www.acast.com/thetipoff")
.description(desc) .description(desc)
.image_uri(Some(String::from( .image_uri(Some(String::from(
"https://imagecdn.acast.com/image?h=1500&w=1500&source=http%3A%2F%2Fi1.sndcdn.\ "https://imagecdn.acast.com/image?h=1500&w=1500&source=http%3A%2F%2Fi1.sndcdn.\
com%2Favatars-000317856075-a2coqz-original.jpg", com%2Favatars-000317856075-a2coqz-original.jpg",
))).source_id(42) )))
.source_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
static ref EXPECTED_STARS: NewShow = { static ref EXPECTED_STARS: NewPodcast = {
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars \ 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 \ is a gripping noir science fiction thriller in 14 episodes: Forbidden \
love, a crashed UFO, an alien body, and an impossible heist unlike any \ love, a crashed UFO, an alien body, and an impossible heist unlike any \
@@ -236,7 +237,7 @@ mod tests {
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\ b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"; 923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
NewShowBuilder::default() NewPodcastBuilder::default()
.title("Steal the Stars") .title("Steal the Stars")
.link("http://tor-labs.com/") .link("http://tor-labs.com/")
.description(descr) .description(descr)
@@ -245,23 +246,24 @@ mod tests {
.build() .build()
.unwrap() .unwrap()
}; };
static ref EXPECTED_CODE: NewShow = { static ref EXPECTED_CODE: NewPodcast = {
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \ let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \
David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \ David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \
Livingston-Gray. Brought to you by @therubyrep."; Livingston-Gray. Brought to you by @therubyrep.";
NewShowBuilder::default() NewPodcastBuilder::default()
.title("Greater Than Code") .title("Greater Than Code")
.link("https://www.greaterthancode.com/") .link("https://www.greaterthancode.com/")
.description(descr) .description(descr)
.image_uri(Some(String::from( .image_uri(Some(String::from(
"http://www.greaterthancode.com/wp-content/uploads/2016/10/code1400-4.jpg", "http://www.greaterthancode.com/wp-content/uploads/2016/10/code1400-4.jpg",
))).source_id(42) )))
.source_id(42)
.build() .build()
.unwrap() .unwrap()
}; };
static ref EXPECTED_ELLINOFRENEIA: NewShow = { static ref EXPECTED_ELLINOFRENEIA: NewPodcast = {
NewShowBuilder::default() NewPodcastBuilder::default()
.title("Ελληνοφρένεια") .title("Ελληνοφρένεια")
.link("https://ellinofreneia.sealabs.net/feed.rss") .link("https://ellinofreneia.sealabs.net/feed.rss")
.description("Ανεπίσημο feed της Ελληνοφρένειας") .description("Ανεπίσημο feed της Ελληνοφρένειας")
@@ -270,8 +272,8 @@ mod tests {
.build() .build()
.unwrap() .unwrap()
}; };
static ref UPDATED_DESC_INTERCEPTED: NewShow = { static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
NewShowBuilder::default() NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill") .title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts") .link("https://theintercept.com/podcasts")
.description("New Description") .description("New Description")
@@ -287,80 +289,73 @@ mod tests {
} }
#[test] #[test]
fn test_new_podcast_intercepted() -> Result<(), Error> { fn test_new_podcast_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_INTERCEPTED, pd); assert_eq!(*EXPECTED_INTERCEPTED, pd);
Ok(())
} }
#[test] #[test]
fn test_new_podcast_lup() -> Result<(), Error> { fn test_new_podcast_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?; let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_LUP, pd); assert_eq!(*EXPECTED_LUP, pd);
Ok(())
} }
#[test] #[test]
fn test_new_podcast_thetipoff() -> Result<(), Error> { fn test_new_podcast_thetipoff() {
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml")?; let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_TIPOFF, pd); assert_eq!(*EXPECTED_TIPOFF, pd);
Ok(())
} }
#[test] #[test]
fn test_new_podcast_steal_the_stars() -> Result<(), Error> { fn test_new_podcast_steal_the_stars() {
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml")?; let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_STARS, pd); assert_eq!(*EXPECTED_STARS, pd);
Ok(())
} }
#[test] #[test]
fn test_new_podcast_greater_than_code() -> Result<(), Error> { fn test_new_podcast_greater_than_code() {
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml")?; let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_CODE, pd); assert_eq!(*EXPECTED_CODE, pd);
Ok(())
} }
#[test] #[test]
fn test_new_podcast_ellinofreneia() -> Result<(), Error> { fn test_new_podcast_ellinofreneia() {
let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml")?; let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewShow::new(&channel, 42); let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_ELLINOFRENEIA, pd); assert_eq!(*EXPECTED_ELLINOFRENEIA, pd);
Ok(())
} }
#[test] #[test]
// This maybe could be a doc test on insert. // This maybe could be a doc test on insert.
fn test_new_podcast_insert() -> Result<(), Error> { fn test_new_podcast_insert() {
truncate_db()?; truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file))?; let channel = Channel::read_from(BufReader::new(file)).unwrap();
let npd = NewShow::new(&channel, 42); let npd = NewPodcast::new(&channel, 42);
npd.insert()?; npd.insert().unwrap();
let pd = dbqueries::get_podcast_from_source_id(42)?; let pd = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(npd, pd); assert_eq!(npd, pd);
assert_eq!(*EXPECTED_INTERCEPTED, npd); assert_eq!(*EXPECTED_INTERCEPTED, npd);
assert_eq!(&*EXPECTED_INTERCEPTED, &pd); assert_eq!(&*EXPECTED_INTERCEPTED, &pd);
Ok(())
} }
#[test] #[test]
@@ -368,64 +363,77 @@ mod tests {
// Currently there's a test that only checks new description or title. // Currently there's a test that only checks new description or title.
// If you have time and want to help, implement the test for the other fields // If you have time and want to help, implement the test for the other fields
// too. // too.
fn test_new_podcast_update() -> Result<(), Error> { fn test_new_podcast_update() {
truncate_db()?; truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED.to_podcast()?; let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
let updated = &*UPDATED_DESC_INTERCEPTED; let updated = &*UPDATED_DESC_INTERCEPTED;
updated.update(old.id())?; updated.update(old.id()).unwrap();
let new = dbqueries::get_podcast_from_source_id(42)?; let mut new = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_ne!(old, new); assert_ne!(old, new);
assert_eq!(old.id(), new.id()); assert_eq!(old.id(), new.id());
assert_eq!(old.source_id(), new.source_id()); assert_eq!(old.source_id(), new.source_id());
assert_eq!(updated, &new); assert_eq!(updated, &new);
assert_ne!(updated, &old); assert_ne!(updated, &old);
Ok(())
// 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] #[test]
fn test_new_podcast_index() -> Result<(), Error> { fn test_new_podcast_index() {
truncate_db()?; truncate_db().unwrap();
// First insert // First insert
assert!(EXPECTED_INTERCEPTED.index().is_ok()); assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Second identical, This should take the early return path // Second identical, This should take the early return path
assert!(EXPECTED_INTERCEPTED.index().is_ok()); assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Get the podcast // Get the podcast
let old = dbqueries::get_podcast_from_source_id(42)?; let old = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert that NewShow is equal to the Indexed one // Assert that NewPodcast is equal to the Indexed one
assert_eq!(&*EXPECTED_INTERCEPTED, &old); assert_eq!(&*EXPECTED_INTERCEPTED, &old);
let updated = &*UPDATED_DESC_INTERCEPTED; let updated = &*UPDATED_DESC_INTERCEPTED;
// Update the podcast // Update the podcast
assert!(updated.index().is_ok()); assert!(updated.index().is_ok());
// Get the new Show // Get the new Podcast
let new = dbqueries::get_podcast_from_source_id(42)?; let new = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert it's diff from the old one. // Assert it's diff from the old one.
assert_ne!(new, old); assert_ne!(new, old);
assert_eq!(new.id(), old.id()); assert_eq!(new.id(), old.id());
assert_eq!(new.source_id(), old.source_id()); assert_eq!(new.source_id(), old.source_id());
Ok(())
} }
#[test] #[test]
fn test_to_podcast() -> Result<(), Error> { fn test_to_podcast() {
// Assert insert() produces the same result that you would get with to_podcast() // Assert insert() produces the same result that you would get with to_podcast()
truncate_db()?; truncate_db().unwrap();
EXPECTED_INTERCEPTED.insert()?; EXPECTED_INTERCEPTED.insert().unwrap();
let old = dbqueries::get_podcast_from_source_id(42)?; let old = dbqueries::get_podcast_from_source_id(42).unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast()?; let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
assert_eq!(old, pd); assert_eq!(old, pd);
// Same as above, diff order // Same as above, diff order
truncate_db()?; truncate_db().unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast()?; let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
// This should error as a unique constrain violation // This should error as a unique constrain violation
assert!(EXPECTED_INTERCEPTED.insert().is_err()); assert!(EXPECTED_INTERCEPTED.insert().is_err());
let old = dbqueries::get_podcast_from_source_id(42)?; let mut old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd); assert_eq!(old, pd);
Ok(())
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);
} }
} }
+173
View File
@@ -0,0 +1,173 @@
use diesel::SaveChangesDsl;
use database::connection;
use errors::DataError;
use models::{Save, Source};
use schema::podcast;
use std::sync::Arc;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "podcast"]
#[derive(Debug, Clone)]
/// Diesel Model of the podcast table.
pub struct Podcast {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
favorite: bool,
archive: bool,
always_dl: bool,
source_id: i32,
}
impl Save<Podcast> for Podcast {
type Error = DataError;
/// Helper method to easily save/"sync" current state of self to the
/// Database.
fn save(&self) -> Result<Podcast, Self::Error> {
let db = connection();
let tempdb = db.get()?;
self.save_changes::<Podcast>(&*tempdb).map_err(From::from)
}
}
impl Podcast {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the Feed `link`.
///
/// Usually the website/homepage of the content creator.
pub fn link(&self) -> &str {
&self.link
}
/// Set the Podcast/Feed `link`.
pub fn set_link(&mut self, value: &str) {
self.link = value.to_string();
}
/// Get the `description`.
pub fn description(&self) -> &str {
&self.description
}
/// Set the `description`.
pub fn set_description(&mut self, value: &str) {
self.description = value.to_string();
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
/// Set the `image_uri`.
pub fn set_image_uri(&mut self, value: Option<&str>) {
self.image_uri = value.map(|x| x.to_string());
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Podcast` Feed.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// Represents the download policy for the `Podcast` Feed.
///
/// Reserved for the use with a Download manager, yet to be implemented.
///
/// If true Podcast Episode should be downloaded automaticly/skipping
/// the selection queue.
pub fn always_download(&self) -> bool {
self.always_dl
}
/// Set the download policy.
pub fn set_always_download(&mut self, b: bool) {
self.always_dl = b
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
}
}
#[derive(Queryable, Debug, Clone)]
/// Diesel Model of the podcast cover query.
/// Used for fetching information about a Podcast's cover.
pub struct PodcastCoverQuery {
id: i32,
title: String,
image_uri: Option<String>,
}
impl From<Podcast> for PodcastCoverQuery {
fn from(p: Podcast) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title,
image_uri: p.image_uri,
}
}
}
impl From<Arc<Podcast>> for PodcastCoverQuery {
fn from(p: Arc<Podcast>) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title.clone(),
image_uri: p.image_uri.clone(),
}
}
}
impl PodcastCoverQuery {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}
@@ -91,7 +91,7 @@ impl Source {
/// Extract Etag and LastModifier from res, and update self and the /// Extract Etag and LastModifier from res, and update self and the
/// corresponding db row. /// corresponding db row.
fn update_etag(mut self, res: &Response) -> Result<Self, DataError> { fn update_etag(&mut self, res: &Response) -> Result<(), DataError> {
let headers = res.headers(); let headers = res.headers();
let etag = headers.get::<ETag>().map(|x| x.tag()); let etag = headers.get::<ETag>().map(|x| x.tag());
@@ -100,24 +100,10 @@ impl Source {
if (self.http_etag() != etag) || (self.last_modified != lmod) { if (self.http_etag() != etag) || (self.last_modified != lmod) {
self.set_http_etag(etag); self.set_http_etag(etag);
self.set_last_modified(lmod); self.set_last_modified(lmod);
self = self.save()?; self.save()?;
} }
Ok(self) Ok(())
}
/// Clear the `HTTP` `Etag` and `Last-modified` headers.
fn clear_etags(mut self) -> Result<Self, DataError> {
if self.http_etag().is_some() || self.last_modified().is_some() {
debug!("Source etags before clear: {:#?}", &self);
self.http_etag = None;
self.last_modified = None;
self = self.save()?;
debug!("Source etags after clear: {:#?}", &self);
info!("Etag fields cleared succesfully for Source: {}", self.uri);
}
Ok(self)
} }
fn make_err(self, context: &str, code: StatusCode) -> DataError { fn make_err(self, context: &str, code: StatusCode) -> DataError {
@@ -135,25 +121,18 @@ impl Source {
// 410: Feed deleted // 410: Feed deleted
// TODO: Rething this api, // TODO: Rething this api,
fn match_status(mut self, res: Response) -> Result<Response, DataError> { fn match_status(mut self, res: Response) -> Result<Response, DataError> {
self.update_etag(&res)?;
let code = res.status(); let code = res.status();
match code {
// If request is succesful save the etag
StatusCode::NotModified | StatusCode::Ok => self = self.update_etag(&res)?,
// Clear the Etag/lmod else
_ => self = self.clear_etags()?,
};
match code { match code {
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)), StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
StatusCode::MovedPermanently | StatusCode::Found | StatusCode::PermanentRedirect => { StatusCode::MovedPermanently => {
error!("Feed was moved permanently."); error!("Feed was moved permanently.");
self = self.update_url(&res)?; self.handle_301(&res)?;
return Err(DataError::F301(self));
}
StatusCode::TemporaryRedirect => {
debug!("307: Temporary Redirect.");
return Err(DataError::F301(self)); return Err(DataError::F301(self));
} }
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)), StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)),
StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)), StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)),
StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)), StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)),
@@ -161,27 +140,21 @@ impl Source {
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)), StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
_ => info!("HTTP StatusCode: {}", code), _ => info!("HTTP StatusCode: {}", code),
}; };
Ok(res) Ok(res)
} }
fn update_url(mut self, res: &Response) -> Result<Self, DataError> { fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
let code = res.status();
let headers = res.headers(); let headers = res.headers();
info!("HTTP StatusCode: {}", code);
debug!("Headers {:#?}", headers);
if let Some(url) = headers.get::<Location>() { if let Some(url) = headers.get::<Location>() {
debug!("Previous Source: {:#?}", &self);
self.set_uri(url.to_string()); self.set_uri(url.to_string());
self = self.clear_etags()?; self.http_etag = None;
self.last_modified = None;
debug!("Updated Source: {:#?}", &self); self.save()?;
info!("Feed url of Source {}, was updated succesfully.", self.id()); info!("Feed url was updated succesfully.");
} }
Ok(self) Ok(())
} }
/// Construct a new `Source` with the given `uri` and index it. /// Construct a new `Source` with the given `uri` and index it.
@@ -204,11 +177,12 @@ impl Source {
pub fn into_feed( pub fn into_feed(
self, self,
client: Client<HttpsConnector<HttpConnector>>, client: Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> impl Future<Item = Feed, Error = DataError> { ) -> impl Future<Item = Feed, Error = DataError> {
let id = self.id(); let id = self.id();
let response = loop_fn(self, move |source| { let response = loop_fn(self, move |source| {
source source
.request_constructor(&client.clone()) .request_constructor(&client.clone(), ignore_etags)
.then(|res| match res { .then(|res| match res {
Ok(response) => Ok(Loop::Break(response)), Ok(response) => Ok(Loop::Break(response)),
Err(err) => match err { Err(err) => match err {
@@ -232,9 +206,12 @@ impl Source {
}) })
} }
// TODO: make ignore_etags an Enum for better ergonomics.
// #bools_are_just_2variant_enmus
fn request_constructor( fn request_constructor(
self, self,
client: &Client<HttpsConnector<HttpConnector>>, client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> impl Future<Item = Response, Error = DataError> { ) -> impl Future<Item = Response, Error = DataError> {
// FIXME: remove unwrap somehow // FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap(); let uri = Uri::from_str(self.uri()).unwrap();
@@ -243,14 +220,16 @@ impl Source {
// Set the UserAgent cause ppl still seem to check it for some reason... // Set the UserAgent cause ppl still seem to check it for some reason...
req.headers_mut().set(UserAgent::new(USER_AGENT)); req.headers_mut().set(UserAgent::new(USER_AGENT));
if let Some(etag) = self.http_etag() { if !ignore_etags {
let tag = vec![EntityTag::new(true, etag.to_owned())]; if let Some(etag) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(tag)); let tag = vec![EntityTag::new(true, etag.to_owned())];
} req.headers_mut().set(IfNoneMatch::Items(tag));
}
if let Some(lmod) = self.last_modified() { if let Some(lmod) = self.last_modified() {
if let Ok(date) = lmod.parse::<HttpDate>() { if let Ok(date) = lmod.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(date)); req.headers_mut().set(IfModifiedSince(date));
}
} }
} }
@@ -275,31 +254,29 @@ fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = Dat
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use failure::Error;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
use database::truncate_db; use database::truncate_db;
use utils::get_feed; use utils::get_feed;
#[test] #[test]
fn test_into_feed() -> Result<(), Error> { fn test_into_feed() {
truncate_db()?; truncate_db().unwrap();
let mut core = Core::new()?; let mut core = Core::new().unwrap();
let client = Client::configure() let client = Client::configure()
.connector(HttpsConnector::new(4, &core.handle())?) .connector(HttpsConnector::new(4, &core.handle()).unwrap())
.build(&core.handle()); .build(&core.handle());
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\ let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill"; com/InterceptedWithJeremyScahill";
let source = Source::from_url(url)?; let source = Source::from_url(url).unwrap();
let id = source.id(); let id = source.id();
let feed = source.into_feed(client); let feed = source.into_feed(client, true);
let feed = core.run(feed)?; let feed = core.run(feed).unwrap();
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id); let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
assert_eq!(expected, feed); assert_eq!(expected, feed);
Ok(())
} }
} }
@@ -35,14 +35,15 @@ pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
.filter_map(|s| { .filter_map(|s| {
if let Err(ref err) = s { if let Err(ref err) = s {
let txt = "If you think this might be a bug please consider filling a report over \ let txt = "If you think this might be a bug please consider filling a report over \
at https://gitlab.gnome.org/World/podcasts/issues/new"; at https://gitlab.gnome.org/World/hammond/issues/new";
error!("Failed to import a Show: {}", err); error!("Failed to import a Show: {}", err);
error!("{}", txt); error!("{}", txt);
} }
s.ok() s.ok()
}).collect(); })
.collect();
Ok(feeds) Ok(feeds)
} }
@@ -91,7 +92,8 @@ pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Erro
} }
Err(err) => Err(err), Err(err) => Err(err),
_ => Ok(()), _ => Ok(()),
}).collect::<Result<Vec<_>, reader::Error>>()?; })
.collect::<Result<Vec<_>, reader::Error>>()?;
Ok(list) Ok(list)
} }
@@ -100,10 +102,9 @@ pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Erro
mod tests { mod tests {
use super::*; use super::*;
use chrono::Local; use chrono::Local;
use failure::Error;
#[test] #[test]
fn test_extract() -> Result<(), Error> { fn test_extract() {
let int_title = String::from("Intercepted with Jeremy Scahill"); let int_title = String::from("Intercepted with Jeremy Scahill");
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill"); let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
let int_desc = let int_desc =
@@ -161,7 +162,6 @@ mod tests {
url: dec_url url: dec_url
}, },
]; ];
assert_eq!(extract_sources(sample1.as_bytes())?, map); assert_eq!(extract_sources(sample1.as_bytes()).unwrap(), map);
Ok(())
} }
} }
@@ -48,13 +48,14 @@ type HttpsClient = Client<HttpsConnector<HttpConnector>>;
/// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes. /// Convert `rss::Channel` into `Feed` -> Index Podcast -> Index Episodes.
pub fn pipeline<'a, S>( pub fn pipeline<'a, S>(
sources: S, sources: S,
ignore_etags: bool,
client: &HttpsClient, client: &HttpsClient,
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a ) -> impl Future<Item = Vec<()>, Error = DataError> + 'a
where where
S: Stream<Item = Source, Error = DataError> + 'a, S: Stream<Item = Source, Error = DataError> + 'a,
{ {
sources sources
.and_then(clone!(client => move |s| s.into_feed(client.clone()))) .and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags)))
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index()))) .and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index())))
// the stream will stop at the first error so // the stream will stop at the first error so
// we ensure that everything will succeded regardless. // we ensure that everything will succeded regardless.
@@ -65,7 +66,7 @@ where
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and /// Creates a tokio `reactor::Core`, and a `hyper::Client` and
/// runs the pipeline to completion. The `reactor::Core` is dropped afterwards. /// runs the pipeline to completion. The `reactor::Core` is dropped afterwards.
pub fn run<S>(sources: S) -> Result<(), DataError> pub fn run<S>(sources: S, ignore_etags: bool) -> Result<(), DataError>
where where
S: IntoIterator<Item = Source>, S: IntoIterator<Item = Source>,
{ {
@@ -76,7 +77,7 @@ where
.build(&handle); .build(&handle);
let stream = iter_ok::<_, DataError>(sources); let stream = iter_ok::<_, DataError>(sources);
let p = pipeline(stream, &client); let p = pipeline(stream, ignore_etags, &client);
core.run(p).map(|_| ()) core.run(p).map(|_| ())
} }
@@ -85,7 +86,6 @@ mod tests {
use super::*; use super::*;
use database::truncate_db; use database::truncate_db;
use dbqueries; use dbqueries;
use failure::Error;
use Source; use Source;
// (path, url) tuples. // (path, url) tuples.
@@ -101,29 +101,28 @@ mod tests {
#[test] #[test]
/// Insert feeds and update/index them. /// Insert feeds and update/index them.
fn test_pipeline() -> Result<(), Error> { fn test_pipeline() {
truncate_db()?; truncate_db().unwrap();
let bad_url = "https://gitlab.gnome.org/World/podcasts.atom"; let bad_url = "https://gitlab.gnome.org/World/hammond.atom";
// if a stream returns error/None it stops // if a stream returns error/None it stops
// bad we want to parse all feeds regardless if one fails // bad we want to parse all feeds regardless if one fails
Source::from_url(bad_url)?; Source::from_url(bad_url).unwrap();
URLS.iter().for_each(|url| { URLS.iter().for_each(|url| {
// Index the urls into the source table. // Index the urls into the source table.
Source::from_url(url).unwrap(); Source::from_url(url).unwrap();
}); });
let sources = dbqueries::get_sources()?; let sources = dbqueries::get_sources().unwrap();
run(sources)?; run(sources, true).unwrap();
let sources = dbqueries::get_sources()?; let sources = dbqueries::get_sources().unwrap();
// Run again to cover Unique constrains erros. // Run again to cover Unique constrains erros.
run(sources)?; run(sources, true).unwrap();
// Assert the index rows equal the controlled results // Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources()?.len(), 6); assert_eq!(dbqueries::get_sources().unwrap().len(), 6);
assert_eq!(dbqueries::get_podcasts()?.len(), 5); assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes()?.len(), 354); assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
Ok(())
} }
} }
@@ -1,7 +1,5 @@
#![allow(warnings)]
table! { table! {
episodes (title, show_id) { episode (title, podcast_id) {
rowid -> Integer, rowid -> Integer,
title -> Text, title -> Text,
uri -> Nullable<Text>, uri -> Nullable<Text>,
@@ -12,17 +10,22 @@ table! {
duration -> Nullable<Integer>, duration -> Nullable<Integer>,
guid -> Nullable<Text>, guid -> Nullable<Text>,
played -> Nullable<Integer>, played -> Nullable<Integer>,
show_id -> Integer, favorite -> Bool,
archive -> Bool,
podcast_id -> Integer,
} }
} }
table! { table! {
shows (id) { podcast (id) {
id -> Integer, id -> Integer,
title -> Text, title -> Text,
link -> Text, link -> Text,
description -> Text, description -> Text,
image_uri -> Nullable<Text>, image_uri -> Nullable<Text>,
favorite -> Bool,
archive -> Bool,
always_dl -> Bool,
source_id -> Integer, source_id -> Integer,
} }
} }
@@ -36,4 +39,4 @@ table! {
} }
} }
allow_tables_to_appear_in_same_query!(episodes, shows, source); allow_tables_to_appear_in_same_query!(episode, podcast, source,);
@@ -7,7 +7,7 @@ use url::{Position, Url};
use dbqueries; use dbqueries;
use errors::DataError; use errors::DataError;
use models::{EpisodeCleanerModel, Save, Show}; use models::{EpisodeCleanerQuery, Podcast, Save};
use xdg_dirs::DL_DIR; use xdg_dirs::DL_DIR;
use std::fs; use std::fs;
@@ -25,7 +25,8 @@ fn download_checker() -> Result<(), DataError> {
return Some(ep); return Some(ep);
} }
None None
}).for_each(|ep| { })
.for_each(|ep| {
ep.set_local_uri(None); ep.set_local_uri(None);
ep.save() ep.save()
.map_err(|err| error!("{}", err)) .map_err(|err| error!("{}", err))
@@ -58,7 +59,7 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
} }
/// Check `ep.local_uri` field and delete the file it points to. /// Check `ep.local_uri` field and delete the file it points to.
fn delete_local_content(ep: &mut EpisodeCleanerModel) -> Result<(), DataError> { fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<(), DataError> {
if ep.local_uri().is_some() { if ep.local_uri().is_some() {
let uri = ep.local_uri().unwrap().to_owned(); let uri = ep.local_uri().unwrap().to_owned();
if Path::new(&uri).exists() { if Path::new(&uri).exists() {
@@ -107,9 +108,9 @@ pub fn url_cleaner(s: &str) -> String {
} }
} }
/// Returns the URI of a Show Downloads given it's title. /// Returns the URI of a Podcast Downloads given it's title.
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> { pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
// It might be better to make it a hash of the title or the Show rowid // It might be better to make it a hash of the title or the podcast rowid
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title); let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
// Create the folder // Create the folder
@@ -122,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, /// Removes all the entries associated with the given show from the database,
/// and deletes all of the downloaded content. /// and deletes all of the downloaded content.
// TODO: Write Tests // TODO: Write Tests
pub fn delete_show(pd: &Show) -> Result<(), DataError> { pub fn delete_show(pd: &Podcast) -> Result<(), DataError> {
dbqueries::remove_feed(pd)?; dbqueries::remove_feed(pd)?;
info!("{} was removed succesfully.", pd.title()); info!("{} was removed succesfully.", pd.title());
@@ -162,7 +163,6 @@ mod tests {
use self::tempdir::TempDir; use self::tempdir::TempDir;
use super::*; use super::*;
use chrono::Duration; use chrono::Duration;
use failure::Error;
use database::truncate_db; use database::truncate_db;
use models::NewEpisodeBuilder; use models::NewEpisodeBuilder;
@@ -170,47 +170,49 @@ mod tests {
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
fn helper_db() -> Result<TempDir, Error> { fn helper_db() -> TempDir {
// Clean the db // Clean the db
truncate_db()?; truncate_db().unwrap();
// Setup tmp file stuff // Setup tmp file stuff
let tmp_dir = TempDir::new("podcasts_test")?; let tmp_dir = TempDir::new("hammond_test").unwrap();
let valid_path = tmp_dir.path().join("virtual_dl.mp3"); let valid_path = tmp_dir.path().join("virtual_dl.mp3");
let bad_path = tmp_dir.path().join("invalid_thing.mp3"); let bad_path = tmp_dir.path().join("invalid_thing.mp3");
let mut tmp_file = File::create(&valid_path)?; let mut tmp_file = File::create(&valid_path).unwrap();
writeln!(tmp_file, "Foooo")?; writeln!(tmp_file, "Foooo").unwrap();
// Setup episodes // Setup episodes
let n1 = NewEpisodeBuilder::default() let n1 = NewEpisodeBuilder::default()
.title("foo_bar".to_string()) .title("foo_bar".to_string())
.show_id(0) .podcast_id(0)
.build() .build()
.unwrap() .unwrap()
.to_episode()?; .to_episode()
.unwrap();
let n2 = NewEpisodeBuilder::default() let n2 = NewEpisodeBuilder::default()
.title("bar_baz".to_string()) .title("bar_baz".to_string())
.show_id(1) .podcast_id(1)
.build() .build()
.unwrap() .unwrap()
.to_episode()?; .to_episode()
.unwrap();
let mut ep1 = dbqueries::get_episode_cleaner_from_pk(n1.title(), n1.show_id())?; let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.podcast_id()).unwrap();
let mut ep2 = dbqueries::get_episode_cleaner_from_pk(n2.title(), n2.show_id())?; let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.podcast_id()).unwrap();
ep1.set_local_uri(Some(valid_path.to_str().unwrap())); ep1.set_local_uri(Some(valid_path.to_str().unwrap()));
ep2.set_local_uri(Some(bad_path.to_str().unwrap())); ep2.set_local_uri(Some(bad_path.to_str().unwrap()));
ep1.save()?; ep1.save().unwrap();
ep2.save()?; ep2.save().unwrap();
Ok(tmp_dir) tmp_dir
} }
#[test] #[test]
fn test_download_checker() -> Result<(), Error> { fn test_download_checker() {
let tmp_dir = helper_db()?; let tmp_dir = helper_db();
download_checker()?; download_checker().unwrap();
let episodes = dbqueries::get_downloaded_episodes()?; let episodes = dbqueries::get_downloaded_episodes().unwrap();
let valid_path = tmp_dir.path().join("virtual_dl.mp3"); let valid_path = tmp_dir.path().join("virtual_dl.mp3");
assert_eq!(episodes.len(), 1); assert_eq!(episodes.len(), 1);
@@ -219,75 +221,69 @@ mod tests {
episodes.first().unwrap().local_uri() episodes.first().unwrap().local_uri()
); );
let _tmp_dir = helper_db()?; let _tmp_dir = helper_db();
download_checker()?; download_checker().unwrap();
let episode = dbqueries::get_episode_cleaner_from_pk("bar_baz", 1)?; let episode = dbqueries::get_episode_from_pk("bar_baz", 1).unwrap();
assert!(episode.local_uri().is_none()); assert!(episode.local_uri().is_none());
Ok(())
} }
#[test] #[test]
fn test_download_cleaner() -> Result<(), Error> { fn test_download_cleaner() {
let _tmp_dir = helper_db()?; let _tmp_dir = helper_db();
let mut episode: EpisodeCleanerModel = let mut episode: EpisodeCleanerQuery =
dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?.into(); dbqueries::get_episode_from_pk("foo_bar", 0).unwrap().into();
let valid_path = episode.local_uri().unwrap().to_owned(); let valid_path = episode.local_uri().unwrap().to_owned();
delete_local_content(&mut episode)?; delete_local_content(&mut episode).unwrap();
assert_eq!(Path::new(&valid_path).exists(), false); assert_eq!(Path::new(&valid_path).exists(), false);
Ok(())
} }
#[test] #[test]
fn test_played_cleaner_expired() -> Result<(), Error> { fn test_played_cleaner_expired() {
let _tmp_dir = helper_db()?; let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?; let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let cleanup_date = Utc::now() - Duration::seconds(1000); let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 - 1; let epoch = cleanup_date.timestamp() as i32 - 1;
episode.set_played(Some(epoch)); episode.set_played(Some(epoch));
episode.save()?; episode.save().unwrap();
let valid_path = episode.local_uri().unwrap().to_owned(); let valid_path = episode.local_uri().unwrap().to_owned();
// This should delete the file // This should delete the file
played_cleaner(cleanup_date)?; played_cleaner(cleanup_date).unwrap();
assert_eq!(Path::new(&valid_path).exists(), false); assert_eq!(Path::new(&valid_path).exists(), false);
Ok(())
} }
#[test] #[test]
fn test_played_cleaner_none() -> Result<(), Error> { fn test_played_cleaner_none() {
let _tmp_dir = helper_db()?; let _tmp_dir = helper_db();
let mut episode = dbqueries::get_episode_cleaner_from_pk("foo_bar", 0)?; let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let cleanup_date = Utc::now() - Duration::seconds(1000); let cleanup_date = Utc::now() - Duration::seconds(1000);
let epoch = cleanup_date.timestamp() as i32 + 1; let epoch = cleanup_date.timestamp() as i32 + 1;
episode.set_played(Some(epoch)); episode.set_played(Some(epoch));
episode.save()?; episode.save().unwrap();
let valid_path = episode.local_uri().unwrap().to_owned(); let valid_path = episode.local_uri().unwrap().to_owned();
// This should not delete the file // This should not delete the file
played_cleaner(cleanup_date)?; played_cleaner(cleanup_date).unwrap();
assert_eq!(Path::new(&valid_path).exists(), true); assert_eq!(Path::new(&valid_path).exists(), true);
Ok(())
} }
#[test] #[test]
fn test_url_cleaner() -> Result<(), Error> { fn test_url_cleaner() {
let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3"; let good_url = "http://traffic.megaphone.fm/FL8608731318.mp3";
let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184"; let bad_url = "http://traffic.megaphone.fm/FL8608731318.mp3?updated=1484685184";
assert_eq!(url_cleaner(bad_url), good_url); assert_eq!(url_cleaner(bad_url), good_url);
assert_eq!(url_cleaner(good_url), good_url); assert_eq!(url_cleaner(good_url), good_url);
assert_eq!(url_cleaner(&format!(" {}\t\n", bad_url)), good_url); assert_eq!(url_cleaner(&format!(" {}\t\n", bad_url)), good_url);
Ok(())
} }
#[test] #[test]
// This test needs access to local system so we ignore it by default. // This test needs access to local system so we ignore it by default.
#[ignore] #[ignore]
fn test_get_dl_folder() -> Result<(), Error> { fn test_get_dl_folder() {
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo"); let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
assert_eq!(get_download_folder("foo")?, foo_); assert_eq!(get_download_folder("foo").unwrap(), foo_);
let _ = fs::remove_dir_all(foo_); let _ = fs::remove_dir_all(foo_);
Ok(())
} }
} }
@@ -1,22 +1,22 @@
[package] [package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"] authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
name = "podcasts-downloader" name = "hammond-downloader"
version = "0.1.0" version = "0.1.0"
workspace = "../" workspace = "../"
[dependencies] [dependencies]
error-chain = "0.12.0" error-chain = "0.11.0"
hyper = "0.11.27" hyper = "0.11.27"
log = "0.4.4" log = "0.4.1"
mime_guess = "1.8.6" mime_guess = "1.8.4"
reqwest = "0.8.8" reqwest = "0.8.5"
tempdir = "0.3.7" tempdir = "0.3.7"
glob = "0.2.11" glob = "0.2.11"
failure = "0.1.2" failure = "0.1.1"
failure_derive = "0.1.2" failure_derive = "0.1.1"
[dependencies.podcasts-data] [dependencies.hammond-data]
path = "../podcasts-data" path = "../hammond-data"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "0.5.1" pretty_assertions = "0.5.1"
@@ -11,8 +11,8 @@ use std::io::{BufWriter, Read, Write};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use podcasts_data::xdg_dirs::PODCASTS_CACHE; use hammond_data::xdg_dirs::HAMMOND_CACHE;
use podcasts_data::{EpisodeWidgetModel, Save, ShowCoverModel}; use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
// use failure::Error; // use failure::Error;
use errors::DownloadError; use errors::DownloadError;
@@ -38,7 +38,7 @@ fn download_into(
dir: &str, dir: &str,
file_title: &str, file_title: &str,
url: &str, url: &str,
progress: Option<Arc<Mutex<dyn DownloadProgress>>>, progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<String, DownloadError> { ) -> Result<String, DownloadError> {
info!("GET request to: {}", url); info!("GET request to: {}", url);
// Haven't included the loop check as // Haven't included the loop check as
@@ -78,7 +78,7 @@ fn download_into(
// Construct a temp file to save desired content. // Construct a temp file to save desired content.
// It has to be a `new_in` instead of new cause rename can't move cross // It has to be a `new_in` instead of new cause rename can't move cross
// filesystems. // filesystems.
let tempdir = TempDir::new_in(PODCASTS_CACHE.to_str().unwrap(), "temp_download")?; let tempdir = TempDir::new_in(HAMMOND_CACHE.to_str().unwrap(), "temp_download")?;
let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),); let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),);
ct_len.map(|x| { ct_len.map(|x| {
@@ -122,12 +122,12 @@ fn save_io(
file: &str, file: &str,
resp: &mut reqwest::Response, resp: &mut reqwest::Response,
content_lenght: Option<u64>, content_lenght: Option<u64>,
progress: Option<Arc<Mutex<dyn DownloadProgress>>>, progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<(), DownloadError> { ) -> Result<(), DownloadError> {
info!("Downloading into: {}", file); info!("Downloading into: {}", file);
let chunk_size = match content_lenght { let chunk_size = match content_lenght {
Some(x) => x as usize / 99, Some(x) => x as usize / 99,
None => 1024, // default chunk size None => 1024 as usize, // default chunk size
}; };
let mut writer = BufWriter::new(File::create(&file)?); let mut writer = BufWriter::new(File::create(&file)?);
@@ -161,9 +161,9 @@ fn save_io(
// TODO: Refactor // TODO: Refactor
pub fn get_episode( pub fn get_episode(
ep: &mut EpisodeWidgetModel, ep: &mut EpisodeWidgetQuery,
download_folder: &str, download_folder: &str,
progress: Option<Arc<Mutex<dyn DownloadProgress>>>, progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<(), DownloadError> { ) -> Result<(), DownloadError> {
// Check if its alrdy downloaded // Check if its alrdy downloaded
if ep.local_uri().is_some() { if ep.local_uri().is_some() {
@@ -196,7 +196,7 @@ pub fn get_episode(
Ok(()) Ok(())
} }
pub fn cache_image(pd: &ShowCoverModel) -> Result<String, DownloadError> { pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
let url = pd let url = pd
.image_uri() .image_uri()
.ok_or_else(|| DownloadError::NoImageLocation)? .ok_or_else(|| DownloadError::NoImageLocation)?
@@ -206,7 +206,7 @@ pub fn cache_image(pd: &ShowCoverModel) -> Result<String, DownloadError> {
return Err(DownloadError::NoImageLocation); return Err(DownloadError::NoImageLocation);
} }
let cache_path = PODCASTS_CACHE let cache_path = HAMMOND_CACHE
.to_str() .to_str()
.ok_or_else(|| DownloadError::InvalidCacheLocation)?; .ok_or_else(|| DownloadError::InvalidCacheLocation)?;
let cache_download_fold = format!("{}{}", cache_path, pd.title().to_owned()); let cache_download_fold = format!("{}{}", cache_path, pd.title().to_owned());
@@ -236,37 +236,35 @@ pub fn cache_image(pd: &ShowCoverModel) -> Result<String, DownloadError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use failure::Error; use hammond_data::dbqueries;
use podcasts_data::dbqueries; use hammond_data::pipeline;
use podcasts_data::pipeline; use hammond_data::Source;
use podcasts_data::Source;
use std::fs; use std::fs;
#[test] #[test]
// This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit // This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it. // to run it.
#[ignore] #[ignore]
fn test_cache_image() -> Result<(), Error> { fn test_cache_image() {
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff"; let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source // Create and index a source
let source = Source::from_url(url)?; let source = Source::from_url(url).unwrap();
// Copy it's id // Copy it's id
let sid = source.id(); let sid = source.id();
// Convert Source it into a future Feed and index it // Convert Source it into a future Feed and index it
pipeline::run(vec![source])?; pipeline::run(vec![source], true).unwrap();
// Get the Podcast // Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid)?.into(); let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
let img_path = cache_image(&pd); let img_path = cache_image(&pd);
let foo_ = format!( let foo_ = format!(
"{}{}/cover.jpeg", "{}{}/cover.jpeg",
PODCASTS_CACHE.to_str().unwrap(), HAMMOND_CACHE.to_str().unwrap(),
pd.title() pd.title()
); );
assert_eq!(img_path?, foo_); assert_eq!(img_path.unwrap(), foo_);
fs::remove_file(foo_)?; fs::remove_file(foo_).unwrap();
Ok(())
} }
} }
@@ -1,6 +1,4 @@
#![allow(bare_trait_objects)] use hammond_data::errors::DataError;
use podcasts_data::errors::DataError;
use reqwest; use reqwest;
use std::io; use std::io;
+25
View File
@@ -0,0 +1,25 @@
#![recursion_limit = "1024"]
#![warn(unused_extern_crates, unused)]
#![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name, option_map_unit_fn))]
#![deny(warnings)]
extern crate failure;
#[macro_use]
extern crate failure_derive;
#[macro_use]
extern crate log;
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate glob;
extern crate hammond_data;
extern crate hyper;
extern crate mime_guess;
extern crate reqwest;
extern crate tempdir;
pub mod downloader;
pub mod errors;
+44
View File
@@ -0,0 +1,44 @@
[package]
authors = ["Jordan Petridis <jordanpetridis@protonmail.com>"]
build = "build.rs"
name = "hammond-gtk"
version = "0.1.0"
workspace = "../"
[dependencies]
chrono = "0.4.2"
crossbeam-channel = "0.1.2"
gdk = "0.8.0"
gdk-pixbuf = "0.4.0"
glib = "0.5.0"
humansize = "1.1.0"
lazy_static = "1.0.0"
log = "0.4.1"
loggerv = "0.7.1"
open = "1.2.1"
rayon = "1.0.1"
send-cell = "0.1.3"
url = "1.7.0"
failure = "0.1.1"
failure_derive = "0.1.1"
regex = "1.0.0"
reqwest = "0.8.5"
serde_json = "1.0.17"
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
[dependencies.gtk]
features = ["v3_22"]
version = "0.4.1"
[dependencies.gio]
features = ["v2_50"]
version = "0.4.0"
[dependencies.hammond-data]
path = "../hammond-data"
[dependencies.hammond-downloader]
path = "../hammond-downloader"
[dev-dependencies]
pretty_assertions = "0.5.1"
+9
View File
@@ -0,0 +1,9 @@
use std::process::Command;
fn main() {
Command::new("glib-compile-resources")
.args(&["--generate", "resources.xml"])
.current_dir("resources")
.status()
.unwrap();
}
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="empty_show">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">application-rss+xml-symbolic</property>
<property name="use_fallback">True</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">This show does not have any episodes</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.4399999999999999"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">If you think this is an Error, Plese consider opening a bug report.</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="empty_view">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">application-rss+xml-symbolic</property>
<property name="use_fallback">True</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">No Feed Subscription Found</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.4399999999999999"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">You can subscribe to feeds using the "+" button</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>
@@ -3,20 +3,20 @@
Copyright (C) 2017 - 2018 Copyright (C) 2017 - 2018
This file is part of GNOME Podcasts. This file is part of Hammond.
GNOME Podcasts is free software: you can redistribute it and/or modify Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
GNOME Podcasts is distributed in the hope that it will be useful, Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with GNOME Podcasts. If not, see <http://www.gnu.org/licenses/>. along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors: Authors:
Jordan Petridis Jordan Petridis
@@ -24,9 +24,9 @@ Tobias Bernard
--> -->
<interface> <interface>
<requires lib="gtk+" version="3.22"/> <requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 --> <!-- interface-license-type gplv3 -->
<!-- interface-name GNOME Podcasts --> <!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop --> <!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 --> <!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard --> <!-- interface-authors Jordan Petridis\nTobias Bernard -->
@@ -35,8 +35,6 @@ Tobias Bernard
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="margin_start">6</property>
<property name="margin_end">6</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
@@ -52,8 +50,9 @@ Tobias Bernard
<object class="GtkLabel" id="title_label"> <object class="GtkLabel" id="title_label">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="label">Episode Title</property> <property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</property> <property name="ellipsize">end</property>
<property name="width_chars">55</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<property name="lines">1</property> <property name="lines">1</property>
@@ -74,7 +73,7 @@ Tobias Bernard
<object class="GtkLabel" id="date_label"> <object class="GtkLabel" id="date_label">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="label">3 Jan</property> <property name="label" translatable="yes">3 Jan</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
@@ -91,7 +90,7 @@ Tobias Bernard
<object class="GtkLabel" id="separator1"> <object class="GtkLabel" id="separator1">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label">·</property> <property name="label" translatable="yes">·</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
<class name="dim-label"/> <class name="dim-label"/>
@@ -107,7 +106,7 @@ Tobias Bernard
<object class="GtkLabel" id="duration_label"> <object class="GtkLabel" id="duration_label">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label">42 min</property> <property name="label" translatable="yes">42 min</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
@@ -124,7 +123,7 @@ Tobias Bernard
<object class="GtkLabel" id="separator2"> <object class="GtkLabel" id="separator2">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label">·</property> <property name="label" translatable="yes">·</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
<class name="dim-label"/> <class name="dim-label"/>
@@ -140,8 +139,7 @@ Tobias Bernard
<object class="GtkLabel" id="local_size"> <object class="GtkLabel" id="local_size">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label">0 MB</property> <property name="label" translatable="yes">0 MB</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
@@ -158,7 +156,7 @@ Tobias Bernard
<object class="GtkLabel" id="prog_separator"> <object class="GtkLabel" id="prog_separator">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label">/</property> <property name="label" translatable="yes">/</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
@@ -175,8 +173,7 @@ Tobias Bernard
<object class="GtkLabel" id="total_size"> <object class="GtkLabel" id="total_size">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="label" translatable="yes">Calculating episode size…</property> <property name="label" translatable="yes">XX MB</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property> <property name="single_line_mode">True</property>
<property name="track_visited_links">False</property> <property name="track_visited_links">False</property>
<style> <style>
@@ -210,41 +207,24 @@ Tobias Bernard
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="layout_style">center</property> <property name="layout_style">center</property>
<child> <child>
<object class="GtkButton" id="cancel_button"> <object class="GtkButton" id="desc_button">
<property name="label" translatable="yes">Cancel</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="download_button">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Download this episode</property>
<property name="halign">center</property> <property name="halign">center</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="always_show_image">True</property>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="icon_name">document-save-symbolic</property> <property name="icon_name">view-paged-symbolic</property>
</object> </object>
</child> </child>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">True</property>
<property name="fill">False</property> <property name="fill">True</property>
<property name="position">1</property> <property name="position">0</property>
<property name="non_homogeneous">True</property> <property name="non_homogeneous">True</property>
</packing> </packing>
</child> </child>
@@ -271,6 +251,45 @@ Tobias Bernard
<property name="non_homogeneous">True</property> <property name="non_homogeneous">True</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkButton" id="download_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">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>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">document-save-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
<property name="non_homogeneous">True</property>
</packing>
</child>
<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="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">3</property>
</packing>
</child>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@@ -290,8 +309,8 @@ Tobias Bernard
<object class="GtkProgressBar" id="progress_bar"> <object class="GtkProgressBar" id="progress_bar">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="no_show_all">True</property> <property name="no_show_all">True</property>
<property name="margin_start">6</property> <property name="margin_left">6</property>
<property name="margin_end">6</property> <property name="margin_right">6</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="pulse_step">0</property> <property name="pulse_step">0</property>
</object> </object>
+399
View File
@@ -0,0 +1,399 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="name">container</property>
<property name="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="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="frame_parent">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_top">32</property>
<property name="margin_bottom">32</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkBox" id="today_box">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Today</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="today_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="yday_box">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Yesterday</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="yday_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="week_box">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Week</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="week_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="month_box">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Month</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="month_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkBox" id="rest_box">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Older</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="rest_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="hexpand">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">5</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>
@@ -3,20 +3,20 @@
Copyright (C) 2017 - 2018 Copyright (C) 2017 - 2018
This file is part of GNOME Podcasts. This file is part of Hammond.
GNOME Podcasts is free software: you can redistribute it and/or modify Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
GNOME Podcasts is distributed in the hope that it will be useful, Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with GNOME Podcasts. If not, see <http://www.gnu.org/licenses/>. along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors: Authors:
Jordan Petridis Jordan Petridis
@@ -24,9 +24,9 @@ Tobias Bernard
--> -->
<interface> <interface>
<requires lib="gtk+" version="3.22"/> <requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 --> <!-- interface-license-type gplv3 -->
<!-- interface-name GNOME Podcasts --> <!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop --> <!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 --> <!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard --> <!-- interface-authors Jordan Petridis\nTobias Bernard -->
@@ -36,11 +36,12 @@ Tobias Bernard
<property name="valign">center</property> <property name="valign">center</property>
<property name="margin_top">6</property> <property name="margin_top">6</property>
<property name="margin_bottom">6</property> <property name="margin_bottom">6</property>
<property name="spacing">6</property>
<child> <child>
<object class="GtkImage" id="cover"> <object class="GtkImage" id="cover">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="margin_start">8</property> <property name="margin_left">6</property>
<property name="pixel_size">64</property> <property name="pixel_size">64</property>
<property name="icon_name">image-x-generic-symbolic</property> <property name="icon_name">image-x-generic-symbolic</property>
</object> </object>
@@ -3,20 +3,20 @@
Copyright (C) 2017 - 2018 Copyright (C) 2017 - 2018
This file is part of GNOEM Podcasts. This file is part of Hammond.
GNOEM Podcasts is free software: you can redistribute it and/or modify Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
GNOEM Podcasts is distributed in the hope that it will be useful, Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with GNOEM Podcasts. If not, see <http://www.gnu.org/licenses/>. along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors: Authors:
Jordan Petridis Jordan Petridis
@@ -24,9 +24,9 @@ Tobias Bernard
--> -->
<interface> <interface>
<requires lib="gtk+" version="3.22"/> <requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 --> <!-- interface-license-type gplv3 -->
<!-- interface-name GNOEM Podcasts --> <!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop --> <!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 --> <!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard --> <!-- interface-authors Jordan Petridis\nTobias Bernard -->
@@ -41,7 +41,10 @@ Tobias Bernard
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="border_width">6</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="orientation">vertical</property>
<property name="spacing">6</property> <property name="spacing">6</property>
<child> <child>
@@ -144,51 +147,13 @@ Tobias Bernard
<object class="GtkHeaderBar" id="headerbar"> <object class="GtkHeaderBar" id="headerbar">
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="show_close_button">True</property> <property name="show_close_button">True</property>
<child type="title">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkStackSwitcher" id="switch">
<property name="visible">True</property>
<property name="can_focus">False</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="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="show_title">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">Show Title</property>
<property name="ellipsize">end</property>
<property name="track_visited_links">False</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<child> <child>
<object class="GtkMenuButton" id="add_toggle"> <object class="GtkMenuButton" id="add_toggle">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">False</property>
<property name="tooltip_text" translatable="yes">Add a new feed</property> <property name="tooltip_text" translatable="yes">Add a new feed</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="popover">add_popover</property>
<child> <child>
<object class="GtkImage" id="add-button-image2"> <object class="GtkImage" id="add-button-image2">
<property name="visible">True</property> <property name="visible">True</property>
@@ -226,11 +191,78 @@ Tobias Bernard
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkMenuButton" id="hamburger"> <object class="GtkBox" id="update_notification">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<child>
<object class="GtkSpinner" id="update_spinner">
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="update_label">
<property name="can_focus">False</property>
<property name="label" translatable="yes">Fetching new episodes</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="position">3</property>
</packing>
</child>
<child type="title">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkStackSwitcher" id="switch">
<property name="visible">True</property>
<property name="can_focus">False</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="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="show_title">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">Show Title</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<child>
<object class="GtkMenuButton" id="menu_button">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">False</property> <property name="receives_default">False</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="use_popover">True</property>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="visible">True</property> <property name="visible">True</property>
@@ -248,28 +280,5 @@ Tobias Bernard
<property name="position">2</property> <property name="position">2</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkMenuButton" id="secondary_menu">
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">view-more-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
<style>
<class name="image-button"/>
</style>
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object> </object>
</interface> </interface>
@@ -18,13 +18,6 @@
<property name="title" translatable="yes" context="shortcut window">Check for new episodes</property> <property name="title" translatable="yes" context="shortcut window">Check for new episodes</property>
</object> </object>
</child> </child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">True</property>
<property name="accelerator">&lt;primary&gt;e</property>
<property name="title" translatable="yes" context="shortcut window">Preferences</property>
</object>
</child>
<child> <child>
<object class="GtkShortcutsShortcut"> <object class="GtkShortcutsShortcut">
<property name="visible">True</property> <property name="visible">True</property>
@@ -1,35 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0 <!-- Generated with glade 3.21.0 -->
Copyright (C) 2017 - 2018
This file is part of GNOME Podcast.
GNOME Podcast is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
GNOME Podcast is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNOME Podcast. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface> <interface>
<requires lib="gtk+" version="3.22"/> <requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name GNOME Podcast -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkRevealer" id="revealer"> <object class="GtkRevealer" id="revealer">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
@@ -49,16 +21,22 @@ Tobias Bernard
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="halign">center</property> <property name="halign">center</property>
<property name="valign">center</property> <property name="valign">center</property>
<property name="margin_start">3</property> <property name="margin_left">3</property>
<property name="margin_end">3</property> <property name="margin_right">3</property>
<property name="spacing">6</property> <property name="spacing">6</property>
<child> <child>
<object class="GtkSpinner" id="spinner"> <object class="GtkLabel" id="text">
<property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_left">12</property>
<property name="margin_right">12</property>
<property name="label" translatable="yes">An in-app action notification</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">True</property> <property name="fill">False</property>
<property name="position">0</property> <property name="position">0</property>
</packing> </packing>
</child> </child>
@@ -89,27 +67,10 @@ Tobias Bernard
<property name="position">1</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkLabel" id="text">
<property name="width_request">150</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_start">12</property>
<property name="margin_end">12</property>
<property name="label" translatable="yes">An in-app action notification</property>
<property name="ellipsize">start</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<child> <child>
<object class="GtkButton" id="undo"> <object class="GtkButton" id="undo">
<property name="label" translatable="yes">Undo</property> <property name="label" translatable="yes">Undo</property>
<property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="halign">center</property> <property name="halign">center</property>
@@ -122,7 +83,7 @@ Tobias Bernard
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">False</property> <property name="fill">False</property>
<property name="pack_type">end</property> <property name="pack_type">end</property>
<property name="position">3</property> <property name="position">2</property>
</packing> </packing>
</child> </child>
</object> </object>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<interface>
<!-- interface-requires gtk+ 3.0 -->
<menu id="menu">
<section>
<item>
<attribute name="label" translatable="yes">_Check for new episodes</attribute>
<attribute name="action">app.refresh</attribute>
<attribute name="accel">&lt;primary&gt;r</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Import Shows</attribute>
<attribute name="action">app.import</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Export Shows</attribute>
<attribute name="action">app.export</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Preferences</attribute>
<attribute name="action">app.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
<attribute name="action">win.show-help-overlay</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Help</attribute>
<attribute name="action">app.help</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_About</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</menu>
</interface>
+300
View File
@@ -0,0 +1,300 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface domain="">
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="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="visible">True</property>
<property name="can_focus">True</property>
<property name="vexpand">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can_default">True</property>
<property name="hexpand">True</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="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="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<property name="min_content_height">80</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label">This is embarrasing!
Sorry, we could not find a description for this Show.</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max_width_chars">70</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="link_button">
<property name="label" translatable="yes">Website</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<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>
<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/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<object class="GtkPopover" id="show_menu">
<property name="can_focus">False</property>
<property name="position">bottom</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton" id="mark_all_watched">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Mark all episodes as listened</property>
<property name="centered">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_child">
<property name="width_request">256</property>
<property name="height_request">256</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="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>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_parent">
<property name="name">fb_parent</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkFlowBox" id="flowbox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</property>
<property name="homogeneous">True</property>
<property name="column_spacing">12</property>
<property name="row_spacing">12</property>
<property name="max_children_per_line">20</property>
<property name="selection_mode">none</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>
+11
View File
@@ -0,0 +1,11 @@
row {
border-bottom: solid 1px rgba(0,0,0, 0.1);
}
row:last-child {
border-bottom: none;
}
list, border {
border-radius: 4px;
}
+6
View File
@@ -0,0 +1,6 @@
# subdir('icons')
install_data('org.gnome.Hammond.desktop', install_dir : datadir + '/applications')
install_data('org.gnome.Hammond.appdata.xml', install_dir : datadir + '/appdata')
install_data('org.gnome.Hammond.gschema.xml', install_dir: join_paths(datadir, 'glib-2.0', 'schemas'))
meson.add_install_script('../../scripts/compile-gschema.py')
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>org.gnome.Hammond</id>
<name>Hammond</name>
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<developer_name>Jordan Petridis</developer_name>
<summary>Modern Podcast Client for the GNOME desktop</summary>
<description xml:lang="en">
<p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
desktop written in Rust.
</p>
</description>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<screenshots>
<screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption>
</screenshot>
<screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.3.4" date="2018-05-20"/>
</releases>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name>
</component>
@@ -0,0 +1,11 @@
[Desktop Entry]
Name=Hammond
GenericName=Podcast Client
Comment=Play, Subscribe and Manage Podcast Feeds.
Icon=multimedia-player
Exec=hammond
Terminal=false
Type=Application
StartupNotify=true
Categories=AudioVideo;Audio;Video;
Keywords=Podcast
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<schemalist> <schemalist>
<enum id="org.gnome.Podcasts.timePeriods"> <enum id="org.gnome.Hammond.timePeriods">
<value nick="seconds" value="1"/> <value nick="seconds" value="1"/>
<value nick="minutes" value="2"/> <value nick="minutes" value="2"/>
<value nick="hours" value="3"/> <value nick="hours" value="3"/>
@@ -9,7 +9,7 @@
<value nick="months" value="6"/> <value nick="months" value="6"/>
</enum> </enum>
<schema path="/org/gnome/Podcasts/" id="org.gnome.Podcasts" gettext-domain="gnome-podcasts"> <schema path="/org/gnome/hammond/" id="org.gnome.Hammond">
<key name="persist-window-geometry-top" type="i"> <key name="persist-window-geometry-top" type="i">
<default>-1</default> <default>-1</default>
<summary>Top position of the last open main window</summary> <summary>Top position of the last open main window</summary>
@@ -45,7 +45,7 @@
<default>1</default> <default>1</default>
<summary>How many periods of time to wait between automatic refreshes</summary> <summary>How many periods of time to wait between automatic refreshes</summary>
</key> </key>
<key name="refresh-interval-period" enum="org.gnome.Podcasts.timePeriods"> <key name="refresh-interval-period" enum="org.gnome.Hammond.timePeriods">
<default>'hours'</default> <default>'hours'</default>
<summary>What period of time to wait between automatic refreshes</summary> <summary>What period of time to wait between automatic refreshes</summary>
</key> </key>
@@ -59,7 +59,7 @@
<default>2</default> <default>2</default>
<summary>How many periods of time to wait between automatic cleanups</summary> <summary>How many periods of time to wait between automatic cleanups</summary>
</key> </key>
<key name="cleanup-age-period" enum="org.gnome.Podcasts.timePeriods"> <key name="cleanup-age-period" enum="org.gnome.Hammond.timePeriods">
<default>'days'</default> <default>'days'</default>
<summary>What period of time to wait between automatic cleanups</summary> <summary>What period of time to wait between automatic cleanups</summary>
</key> </key>
@@ -1,20 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/org/gnome/Podcasts/"> <gresource prefix="/org/gnome/Hammond/">
<file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/empty_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/home_view.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/empty_show.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/home_episode.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/inapp_notif.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/hamburger.ui</file> <file compressed="true" preprocess="xml-stripblanks">gtk/menus.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/show_menu.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/help-overlay.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" preprocess="xml-stripblanks">icons/skip-back-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks">icons/skip-forward-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/prefs.ui</file>
<file compressed="true">gtk/style.css</file> <file compressed="true">gtk/style.css</file>
</gresource> </gresource>
</gresources> </gresources>
+299
View File
@@ -0,0 +1,299 @@
#![allow(new_without_default)]
use gio::{
ActionMapExt, ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt,
SimpleAction, SimpleActionExt,
};
use glib;
use gtk;
use gtk::prelude::*;
use gtk::SettingsExt as GtkSettingsExt;
use crossbeam_channel::{unbounded, Sender};
use hammond_data::{Podcast, dbqueries};
use headerbar::Header;
use settings::{self, WindowGeometry};
use stacks::{Content, PopulatedState};
use utils;
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::{about_dialog, mark_all_notif, remove_show_notif};
use std::rc::Rc;
use std::sync::Arc;
/// 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 {
RefreshAllViews,
RefreshEpisodesView,
RefreshEpisodesViewBGR,
RefreshShowsView,
ReplaceWidget(Arc<Podcast>),
RefreshWidgetIfSame(i32),
ShowWidgetAnimated,
ShowShowsAnimated,
HeaderBarShowTile(String),
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>),
ErrorNotification(String),
AboutWidget(i32),
}
#[derive(Debug)]
pub struct App {
app_instance: gtk::Application,
settings: Settings,
}
impl App {
pub fn new() -> App {
let settings = Settings::new("org.gnome.Hammond");
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
.expect("Application Initialization failed...");
// Weird magic I copy-pasted that sets the Application Name in the Shell.
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
let cleanup_date = settings::get_cleanup_date(&settings);
utils::cleanup(cleanup_date);
application.connect_startup(clone!(settings => move |app| {
let (sender, receiver) = unbounded();
App::setup_gactions(&app, &sender);
app.connect_activate(clone!(sender, settings => move |app| {
// Get the current window (if any)
if let Some(window) = app.get_active_window() {
// Already open, just raise the window
window.present();
} else {
// Time to open one!
// Create the main window
let window = gtk::ApplicationWindow::new(&app);
window.set_title("Hammond");
window.connect_delete_event(clone!(app, settings => move |window, _| {
WindowGeometry::from_window(&window).write(&settings);
app.quit();
Inhibit(false)
}));
// Create a content instance
let content =
Rc::new(Content::new(sender.clone()).expect(
"Content Initialization failed."));
// Create the headerbar
let header = Rc::new(Header::new(&content, &window, &sender));
action!(window, "menu", clone!(header => move |_, _| {
header.open_menu();
}));
// Add the content main stack to the overlay.
let overlay = gtk::Overlay::new();
overlay.add(&content.get_stack());
// Add the overlay to the main window
window.add(&overlay);
WindowGeometry::from_settings(&settings).apply(&window);
App::setup_timed_callbacks(&sender, &settings);
window.show_all();
window.activate();
gtk::timeout_add(50, clone!(sender, receiver => move || {
// Uses receiver, content, header, sender, overlay
match receiver.try_recv() {
Ok(Action::RefreshAllViews) => content.update(),
Ok(Action::RefreshShowsView) => content.update_shows_view(),
Ok(Action::RefreshWidgetIfSame(id)) =>
content.update_widget_if_same(id),
Ok(Action::RefreshEpisodesView) => content.update_home(),
Ok(Action::RefreshEpisodesViewBGR) =>
content.update_home_if_background(),
Ok(Action::ReplaceWidget(pd)) => {
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.replace_widget(pd.clone())
.map_err(|err| error!("Failed to update ShowWidget: {}", err))
.map_err(|_|
error!("Failed ot update ShowWidget {}", pd.title()))
.ok();
}
Ok(Action::ShowWidgetAnimated) => {
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut().switch_visible(
PopulatedState::Widget,
gtk::StackTransitionType::SlideLeft,
);
}
Ok(Action::ShowShowsAnimated) => {
let shows = content.get_shows();
let mut pop = shows.borrow().populated();
pop.borrow_mut()
.switch_visible(PopulatedState::View,
gtk::StackTransitionType::SlideRight);
}
Ok(Action::HeaderBarShowTile(title)) =>
header.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => header.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) =>
header.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) =>
header.hide_update_notification(),
Ok(Action::MarkAllPlayerNotification(pd)) => {
let notif = mark_all_notif(pd, &sender);
notif.show(&overlay);
}
Ok(Action::RemoveShow(pd)) => {
let notif = remove_show_notif(pd, sender.clone());
notif.show(&overlay);
}
Ok(Action::ErrorNotification(err)) => {
error!("An error notification was triggered: {}", err);
let callback = || glib::Continue(false);
let notif = InAppNotification::new(&err, callback,
|| {}, UndoState::Hidden);
notif.show(&overlay);
}
Ok(Action::AboutWidget(rowid)) => {
if let Ok(d) = dbqueries::get_episode_description(rowid) {
if let Some(_desc) = d {
let _stack = content.get_visible_stack();
}
}
}
Err(_) => (),
}
Continue(true)
}));
}
}));
}));
App {
app_instance: application,
settings,
}
}
fn setup_timed_callbacks(sender: &Sender<Action>, settings: &Settings) {
App::setup_dark_theme(settings);
App::setup_refresh_on_startup(&sender, settings);
App::setup_auto_refresh(&sender, settings);
}
fn setup_dark_theme(settings: &Settings) {
let gtk_settings = gtk::Settings::get_default().unwrap();
let enabled = settings.get_boolean("dark-theme");
gtk_settings.set_property_gtk_application_prefer_dark_theme(enabled);
}
fn setup_refresh_on_startup(sender: &Sender<Action>, settings: &Settings) {
// Update the feeds right after the Application is initialized.
let sender = sender.clone();
if settings.get_boolean("refresh-on-startup") {
info!("Refresh on startup.");
// The ui loads async, after initialization
// so we need to delay this a bit so it won't block
// requests that will come from loading the gui on startup.
gtk::timeout_add(1500, move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
});
}
}
fn setup_auto_refresh(sender: &Sender<Action>, settings: &Settings) {
let refresh_interval = settings::get_refresh_interval(&settings).num_seconds() as u32;
info!("Auto-refresh every {:?} seconds.", refresh_interval);
let sender = sender.clone();
gtk::timeout_add_seconds(refresh_interval, move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(true)
});
}
/// Define the `GAction`s.
///
/// Used in menus and the keyboard shortcuts dialog.
fn setup_gactions(app: &gtk::Application, sender: &Sender<Action>) {
// Create the `refresh` action.
//
// This will trigger a refresh of all the shows in the database.
action!(
app,
"refresh",
clone!(sender => move |_, _| {
gtk::idle_add(clone!(sender => move || {
let s: Option<Vec<_>> = None;
utils::refresh(s, sender.clone());
glib::Continue(false)
}));
})
);
app.set_accels_for_action("app.refresh", &["<primary>r"]);
// Create the `OPML` import action
action!(
app,
"import",
clone!(sender, app => move |_, _| {
let window = app.get_active_window().expect("Failed to get active window");
utils::on_import_clicked(&window, &sender);
})
);
// Create the action that shows a `gtk::AboutDialog`
action!(
app,
"about",
clone!(app => move |_, _| {
let window = app.get_active_window().expect("Failed to get active window");
about_dialog(&window);
})
);
// Create the quit action
action!(app, "quit", clone!(app => move |_, _| app.quit()));
app.set_accels_for_action("app.quit", &["<primary>q"]);
// Bind the hamburger menu button to `F10`
app.set_accels_for_action("win.menu", &["F10"]);
}
pub fn run(self) {
ApplicationExtManual::run(&self.app_instance, &[]);
}
}
+223
View File
@@ -0,0 +1,223 @@
use gio::MenuModel;
use glib;
use gtk;
use gtk::prelude::*;
use failure::Error;
use failure::ResultExt;
use url::Url;
use crossbeam_channel::Sender;
use hammond_data::{dbqueries, Source};
use app::Action;
use stacks::Content;
use utils::{itunes_to_rss, refresh};
#[derive(Debug, Clone)]
// TODO: split this into smaller
pub struct Header {
container: gtk::HeaderBar,
add_toggle: gtk::MenuButton,
switch: gtk::StackSwitcher,
back: gtk::Button,
show_title: gtk::Label,
update_box: gtk::Box,
update_label: gtk::Label,
update_spinner: gtk::Spinner,
menu_button: gtk::MenuButton,
app_menu: MenuModel,
}
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
let menus = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/menus.ui");
let header = builder.get_object("headerbar").unwrap();
let add_toggle = builder.get_object("add_toggle").unwrap();
let switch = builder.get_object("switch").unwrap();
let back = builder.get_object("back").unwrap();
let show_title = builder.get_object("show_title").unwrap();
let update_box = builder.get_object("update_notification").unwrap();
let update_label = builder.get_object("update_label").unwrap();
let update_spinner = builder.get_object("update_spinner").unwrap();
let menu_button = builder.get_object("menu_button").unwrap();
let app_menu = menus.get_object("menu").unwrap();
Header {
container: header,
add_toggle,
switch,
back,
show_title,
update_box,
update_label,
update_spinner,
menu_button,
app_menu,
}
}
}
// TODO: Refactor components into smaller state machines
impl Header {
pub fn new(
content: &Content,
window: &gtk::ApplicationWindow,
sender: &Sender<Action>,
) -> Header {
let h = Header::default();
h.init(content, window, &sender);
h
}
pub fn init(
&self,
content: &Content,
window: &gtk::ApplicationWindow,
sender: &Sender<Action>,
) {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
let add_button: gtk::Button = builder.get_object("add_button").unwrap();
let result_label: gtk::Label = builder.get_object("result_label").unwrap();
self.switch.set_stack(&content.get_stack());
new_url.connect_changed(clone!(add_button => move |url| {
on_url_change(url, &result_label, &add_button)
.map_err(|err| error!("Error: {}", err))
.ok();
}));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
on_add_bttn_clicked(&new_url, sender.clone())
.map_err(|err| error!("Error: {}", err))
.ok();
add_popover.hide();
}));
self.add_toggle.set_popover(&add_popover);
// 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.connect_clicked(
clone!(switch, add_toggle, show_title, sender => move |back| {
switch.show();
add_toggle.show();
back.hide();
show_title.hide();
sender.send(Action::ShowShowsAnimated)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}),
);
self.menu_button.set_menu_model(Some(&self.app_menu));
}
pub fn switch_to_back(&self, title: &str) {
self.switch.hide();
self.add_toggle.hide();
self.back.show();
self.set_show_title(title);
self.show_title.show();
}
pub fn switch_to_normal(&self) {
self.switch.show();
self.add_toggle.show();
self.back.hide();
self.show_title.hide();
}
pub fn set_show_title(&self, title: &str) {
self.show_title.set_text(title)
}
pub fn show_update_notification(&self) {
self.update_spinner.start();
self.update_box.show();
self.update_spinner.show();
self.update_label.show();
}
pub fn hide_update_notification(&self) {
self.update_spinner.stop();
self.update_box.hide();
self.update_spinner.hide();
self.update_label.hide();
}
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("");
gtk::idle_add(move || {
refresh(Some(vec![source.clone()]), sender.clone());
glib::Continue(false)
});
Ok(())
}
// FIXME: THIS SUCKS!
fn on_url_change(
entry: &gtk::Entry,
result: &gtk::Label,
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(())
}
}
}
+95
View File
@@ -0,0 +1,95 @@
#![cfg_attr(
feature = "cargo-clippy",
allow(clone_on_ref_ptr, blacklisted_name, match_same_arms, option_map_unit_fn)
)]
#![allow(unknown_lints)]
#![warn(unused_extern_crates, unused)]
#![deny(warnings)]
extern crate gdk;
extern crate gdk_pixbuf;
extern crate gio;
extern crate glib;
extern crate gtk;
#[macro_use]
extern crate failure;
// #[macro_use]
// extern crate failure_derive;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
extern crate chrono;
extern crate crossbeam_channel;
extern crate hammond_data;
extern crate hammond_downloader;
extern crate html2pango;
extern crate humansize;
extern crate loggerv;
extern crate open;
extern crate rayon;
extern crate regex;
extern crate reqwest;
extern crate send_cell;
extern crate serde_json;
extern crate url;
use log::Level;
use gtk::prelude::*;
// http://gtk-rs.org/tuto/closures
#[macro_export]
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
mod stacks;
mod widgets;
mod app;
mod headerbar;
mod manager;
mod settings;
mod static_resource;
mod utils;
use app::App;
fn main() {
// TODO: make the the logger a cli -vv option
loggerv::init_with_level(Level::Info).expect("Error initializing loggerv.");
gtk::init().expect("Error initializing gtk.");
static_resource::init().expect("Something went wrong with the resource file initialization.");
// Add custom style
let provider = gtk::CssProvider::new();
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/Hammond/gtk/style.css");
gtk::StyleContext::add_provider_for_screen(
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
&provider,
600,
);
App::new().run();
}
@@ -1,9 +1,9 @@
use failure::Error; use failure::Error;
use rayon; use rayon;
// use podcasts_data::Episode; // use hammond_data::Episode;
use podcasts_data::dbqueries; use hammond_data::dbqueries;
use podcasts_downloader::downloader::{get_episode, DownloadProgress}; use hammond_downloader::downloader::{get_episode, DownloadProgress};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
@@ -11,10 +11,10 @@ use std::sync::{Arc, Mutex, RwLock};
// use std::path::PathBuf; // use std::path::PathBuf;
// This is messy, undocumented and hacky af. // This is messy, undocumented and hacky af.
// I am terrible at writing downloaders and download managers. // I am terrible at writting downloaders and download managers.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Progress { pub struct Progress {
total_bytes: u64, total_bytes: u64,
downloaded_bytes: u64, downloaded_bytes: u64,
cancel: bool, cancel: bool,
@@ -31,7 +31,7 @@ impl Default for Progress {
} }
impl Progress { impl Progress {
pub(crate) fn get_fraction(&self) -> f64 { pub fn get_fraction(&self) -> f64 {
let ratio = self.downloaded_bytes as f64 / self.total_bytes as f64; let ratio = self.downloaded_bytes as f64 / self.total_bytes as f64;
debug!("{:?}", self); debug!("{:?}", self);
debug!("Ratio completed: {}", ratio); debug!("Ratio completed: {}", ratio);
@@ -42,15 +42,15 @@ impl Progress {
ratio ratio
} }
pub(crate) fn get_total_size(&self) -> u64 { pub fn get_total_size(&self) -> u64 {
self.total_bytes self.total_bytes
} }
pub(crate) fn get_downloaded(&self) -> u64 { pub fn get_downloaded(&self) -> u64 {
self.downloaded_bytes self.downloaded_bytes
} }
pub(crate) fn cancel(&mut self) { pub fn cancel(&mut self) {
self.cancel = true; self.cancel = true;
} }
} }
@@ -70,18 +70,18 @@ impl DownloadProgress for Progress {
} }
lazy_static! { lazy_static! {
pub(crate) static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> = pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> =
{ Arc::new(RwLock::new(HashMap::new())) }; { Arc::new(RwLock::new(HashMap::new())) };
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap(); static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
} }
pub(crate) fn add(id: i32, directory: String) -> Result<(), Error> { pub fn add(id: i32, directory: String) -> Result<(), Error> {
// Create a new `Progress` struct to keep track of dl progress. // Create a new `Progress` struct to keep track of dl progress.
let prog = Arc::new(Mutex::new(Progress::default())); let prog = Arc::new(Mutex::new(Progress::default()));
match ACTIVE_DOWNLOADS.write() { match ACTIVE_DOWNLOADS.write() {
Ok(mut guard) => guard.insert(id, prog.clone()), Ok(mut guard) => guard.insert(id, prog.clone()),
Err(err) => return Err(format_err!("ActiveDownloads: {}.", err)), Err(err) => return Err(format_err!("ActiveDonwloads: {}.", err)),
}; };
DLPOOL.spawn(move || { DLPOOL.spawn(move || {
@@ -110,82 +110,75 @@ pub(crate) fn add(id: i32, directory: String) -> Result<(), Error> {
mod tests { mod tests {
use super::*; use super::*;
use podcasts_data::dbqueries; use hammond_data::dbqueries;
use podcasts_data::pipeline; use hammond_data::pipeline;
use podcasts_data::utils::get_download_folder; use hammond_data::utils::get_download_folder;
use podcasts_data::{Episode, Save, Source}; use hammond_data::{Episode, Source};
use podcasts_downloader::downloader::get_episode; use hammond_downloader::downloader::get_episode;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::{thread, time}; use std::{thread, time};
#[test] #[test]
// This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit // This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it. // to run it.
#[ignore] #[ignore]
// THIS IS NOT A RELIABLE TEST // THIS IS NOT A RELIABLE TEST
// Just quick sanity check // Just quick sanity check
fn test_start_dl() -> Result<(), Error> { fn test_start_dl() {
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff"; let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source // Create and index a source
let mut source = Source::from_url(url)?; let source = Source::from_url(url).unwrap();
// Copy its id // Copy it's id
let sid = source.id(); let sid = source.id();
source.set_http_etag(None); pipeline::run(vec![source], true).unwrap();
source.set_last_modified(None);
source.save()?;
pipeline::run(vec![source])?;
// Get the podcast // Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid)?; let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let title = "Coming soon... The Tip Off"; let title = "Coming soon... The Tip Off";
// Get an episode // Get an episode
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id())?; let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
let download_fold = get_download_folder(&pd.title())?; let download_fold = get_download_folder(&pd.title()).unwrap();
let fold2 = download_fold.clone(); let fold2 = download_fold.clone();
add(episode.rowid(), download_fold)?; add(episode.rowid(), download_fold).unwrap();
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1); assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
// Give it some time to download the file // Give it soem time to download the file
thread::sleep(time::Duration::from_secs(20)); thread::sleep(time::Duration::from_secs(20));
let final_path = format!("{}/{}.mp3", &fold2, episode.rowid()); let final_path = format!("{}/{}.mp3", &fold2, episode.rowid());
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 0);
assert!(Path::new(&final_path).exists()); assert!(Path::new(&final_path).exists());
fs::remove_file(final_path)?; fs::remove_file(final_path).unwrap();
Ok(())
} }
#[test] #[test]
// This test needs access to local system so we ignore it by default. // This test needs access to local system so we ignore it by default.
#[ignore] #[ignore]
fn test_dl_steal_the_stars() -> Result<(), Error> { fn test_dl_steal_the_stars() {
let url = let url =
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars"; "https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
// Create and index a source // Create and index a source
let mut source = Source::from_url(url)?; let source = Source::from_url(url).unwrap();
// Copy its id // Copy it's id
let sid = source.id(); let sid = source.id();
source.set_http_etag(None); pipeline::run(vec![source], true).unwrap();
source.set_last_modified(None);
source.save()?;
pipeline::run(vec![source])?;
// Get the podcast // Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid)?; let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let title = "Introducing Steal the Stars"; let title = "Introducing Steal the Stars";
// Get an episode // Get an episode
let mut episode = dbqueries::get_episode_from_pk(title, pd.id())?.into(); let mut episode = dbqueries::get_episode_from_pk(title, pd.id())
let download_fold = get_download_folder(&pd.title())?; .unwrap()
.into();
let download_fold = get_download_folder(&pd.title()).unwrap();
get_episode(&mut episode, &download_fold, None)?; get_episode(&mut episode, &download_fold, None).unwrap();
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid()); let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
assert!(Path::new(&final_path).exists()); assert!(Path::new(&final_path).exists());
fs::remove_file(final_path)?; fs::remove_file(final_path).unwrap();
Ok(())
} }
} }
@@ -6,7 +6,7 @@ use gtk::GtkWindowExt;
use chrono::prelude::*; use chrono::prelude::*;
use chrono::Duration; use chrono::Duration;
pub(crate) struct WindowGeometry { pub struct WindowGeometry {
left: i32, left: i32,
top: i32, top: i32,
width: i32, width: i32,
@@ -15,7 +15,7 @@ pub(crate) struct WindowGeometry {
} }
impl WindowGeometry { impl WindowGeometry {
pub(crate) fn from_window(window: &gtk::ApplicationWindow) -> WindowGeometry { pub fn from_window(window: &gtk::ApplicationWindow) -> WindowGeometry {
let position = window.get_position(); let position = window.get_position();
let size = window.get_size(); let size = window.get_size();
let left = position.0; let left = position.0;
@@ -33,7 +33,7 @@ impl WindowGeometry {
} }
} }
pub(crate) fn from_settings(settings: &gio::Settings) -> WindowGeometry { pub fn from_settings(settings: &gio::Settings) -> WindowGeometry {
let top = settings.get_int("persist-window-geometry-top"); let top = settings.get_int("persist-window-geometry-top");
let left = settings.get_int("persist-window-geometry-left"); let left = settings.get_int("persist-window-geometry-left");
let width = settings.get_int("persist-window-geometry-width"); let width = settings.get_int("persist-window-geometry-width");
@@ -49,7 +49,7 @@ impl WindowGeometry {
} }
} }
pub(crate) fn apply(&self, window: &gtk::ApplicationWindow) { pub fn apply(&self, window: &gtk::ApplicationWindow) {
if self.width > 0 && self.height > 0 { if self.width > 0 && self.height > 0 {
window.resize(self.width, self.height); window.resize(self.width, self.height);
} }
@@ -61,7 +61,7 @@ impl WindowGeometry {
} }
} }
pub(crate) fn write(&self, settings: &gio::Settings) { pub fn write(&self, settings: &gio::Settings) {
settings.set_int("persist-window-geometry-left", self.left); settings.set_int("persist-window-geometry-left", self.left);
settings.set_int("persist-window-geometry-top", self.top); settings.set_int("persist-window-geometry-top", self.top);
settings.set_int("persist-window-geometry-width", self.width); settings.set_int("persist-window-geometry-width", self.width);
@@ -70,14 +70,14 @@ impl WindowGeometry {
} }
} }
pub(crate) fn get_refresh_interval(settings: &Settings) -> Duration { pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = i64::from(settings.get_int("refresh-interval-time")); let time = i64::from(settings.get_int("refresh-interval-time"));
let period = settings.get_string("refresh-interval-period").unwrap(); let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str()) time_period_to_duration(time, period.as_str())
} }
pub(crate) fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> { pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = i64::from(settings.get_int("cleanup-age-time")); let time = i64::from(settings.get_int("cleanup-age-time"));
let period = settings.get_string("cleanup-age-period").unwrap(); let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str()); let duration = time_period_to_duration(time, period.as_str());
@@ -85,7 +85,7 @@ pub(crate) fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
Utc::now() - duration Utc::now() - duration
} }
pub(crate) fn time_period_to_duration(time: i64, period: &str) -> Duration { pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period { match period {
"weeks" => Duration::weeks(time), "weeks" => Duration::weeks(time),
"days" => Duration::days(time), "days" => Duration::days(time),
@@ -10,16 +10,8 @@ use stacks::{HomeStack, ShowStack};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use i18n::i18n;
#[derive(Debug, Clone, Copy)]
pub(crate) enum State {
Populated,
Empty,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Content { pub struct Content {
stack: gtk::Stack, stack: gtk::Stack,
shows: Rc<RefCell<ShowStack>>, shows: Rc<RefCell<ShowStack>>,
home: Rc<RefCell<HomeStack>>, home: Rc<RefCell<HomeStack>>,
@@ -27,29 +19,28 @@ pub(crate) struct Content {
} }
impl Content { impl Content {
pub(crate) fn new(sender: &Sender<Action>) -> Result<Rc<Content>, Error> { pub fn new(sender: Sender<Action>) -> Result<Content, Error> {
let stack = gtk::Stack::new(); let stack = gtk::Stack::new();
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?)); let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone()))); let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?));
stack.add_titled(&home.borrow().get_stack(), "home", &i18n("New")); stack.add_titled(&home.borrow().get_stack(), "home", "Recent");
stack.add_titled(&shows.borrow().get_stack(), "shows", &i18n("Shows")); stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
let con = Content { Ok(Content {
stack, stack,
shows, shows,
home, home,
sender: sender.clone(), sender,
}; })
Ok(Rc::new(con))
} }
pub(crate) fn update(&self) { pub fn update(&self) {
self.update_home(); self.update_home();
self.update_shows(); self.update_shows();
} }
pub(crate) fn update_home(&self) { pub fn update_home(&self) {
self.home self.home
.borrow_mut() .borrow_mut()
.update() .update()
@@ -57,7 +48,7 @@ impl Content {
.ok(); .ok();
} }
pub(crate) fn update_home_if_background(&self) { pub fn update_home_if_background(&self) {
if self.stack.get_visible_child_name() != Some("home".into()) { if self.stack.get_visible_child_name() != Some("home".into()) {
self.update_home(); self.update_home();
} }
@@ -71,7 +62,7 @@ impl Content {
.ok(); .ok();
} }
pub(crate) fn update_shows_view(&self) { pub fn update_shows_view(&self) {
self.shows self.shows
.borrow_mut() .borrow_mut()
.update() .update()
@@ -79,7 +70,7 @@ impl Content {
.ok(); .ok();
} }
pub(crate) fn update_widget_if_same(&self, pid: i32) { pub fn update_widget_if_same(&self, pid: i32) {
let pop = self.shows.borrow().populated(); let pop = self.shows.borrow().populated();
pop.borrow_mut() pop.borrow_mut()
.update_widget_if_same(pid) .update_widget_if_same(pid)
@@ -87,29 +78,15 @@ impl Content {
.ok(); .ok();
} }
pub(crate) fn get_stack(&self) -> gtk::Stack { pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone() self.stack.clone()
} }
pub(crate) fn get_shows(&self) -> Rc<RefCell<ShowStack>> { pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
self.shows.clone() self.shows.clone()
} }
pub(crate) fn switch_to_empty_views(&self) { pub fn get_visible_stack(&self) -> Option<gtk::Stack> {
use gtk::StackTransitionType::*; self.stack.get_visible_child().and_then(|w| w.downcast::<gtk::Stack>().ok())
self.home
.borrow_mut()
.switch_visible(State::Empty, Crossfade);
self.shows.borrow_mut().switch_visible(State::Empty);
}
pub(crate) fn switch_to_populated(&self) {
use gtk::StackTransitionType::*;
self.home
.borrow_mut()
.switch_visible(State::Populated, Crossfade);
self.shows.borrow_mut().switch_visible(State::Populated);
} }
} }
+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(())
}
}
+9
View File
@@ -0,0 +1,9 @@
mod content;
mod home;
mod populated;
mod show;
pub use self::content::Content;
pub use self::home::HomeStack;
pub use self::populated::{PopulatedStack, PopulatedState};
pub use self::show::{ShowStack, ShowState};
@@ -5,8 +5,8 @@ use gtk::StackTransitionType;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use failure::Error; use failure::Error;
use podcasts_data::dbqueries; use hammond_data::dbqueries;
use podcasts_data::Show; use hammond_data::Podcast;
use app::Action; use app::Action;
use widgets::{ShowWidget, ShowsView}; use widgets::{ShowWidget, ShowsView};
@@ -15,13 +15,13 @@ use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub(crate) enum PopulatedState { pub enum PopulatedState {
View, View,
Widget, Widget,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PopulatedStack { pub struct PopulatedStack {
container: gtk::Box, container: gtk::Box,
populated: Rc<ShowsView>, populated: Rc<ShowsView>,
show: Rc<ShowWidget>, show: Rc<ShowWidget>,
@@ -31,34 +31,36 @@ pub(crate) struct PopulatedStack {
} }
impl PopulatedStack { impl PopulatedStack {
pub(crate) fn new(sender: Sender<Action>) -> PopulatedStack { pub fn new(sender: Sender<Action>) -> Result<PopulatedStack, Error> {
let stack = gtk::Stack::new(); let stack = gtk::Stack::new();
let state = PopulatedState::View; let state = PopulatedState::View;
let populated = ShowsView::new(sender.clone(), None); let populated = ShowsView::new(sender.clone())?;
let show = Rc::new(ShowWidget::default()); let show = Rc::new(ShowWidget::default());
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0); let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
stack.add_named(populated.view.container(), "shows"); stack.add_named(&populated.container, "shows");
stack.add_named(show.view.container(), "widget"); stack.add_named(&show.container, "widget");
container.add(&stack); container.add(&stack);
container.show_all(); container.show_all();
PopulatedStack { let show = PopulatedStack {
container, container,
stack, stack,
populated, populated,
show, show,
state, state,
sender, sender,
} };
Ok(show)
} }
pub(crate) fn update(&mut self) { pub fn update(&mut self) {
self.update_widget().map_err(|err| format!("{}", err)).ok(); self.update_widget().map_err(|err| format!("{}", err)).ok();
self.update_shows().map_err(|err| format!("{}", err)).ok(); self.update_shows().map_err(|err| format!("{}", err)).ok();
} }
pub(crate) fn update_shows(&mut self) -> Result<(), Error> { pub fn update_shows(&mut self) -> Result<(), Error> {
// The current visible child might change depending on // The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have // removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same. // to make sure it will stay the same.
@@ -69,36 +71,36 @@ impl PopulatedStack {
Ok(()) Ok(())
} }
pub(crate) fn replace_shows(&mut self) -> Result<(), Error> { pub fn replace_shows(&mut self) -> Result<(), Error> {
let old = &self.populated.view.container().clone(); let old = &self.populated.container.clone();
debug!("Name: {:?}", WidgetExt::get_name(old)); debug!("Name: {:?}", WidgetExt::get_name(old));
let vadj = self.populated.view.get_vadjustment(); self.populated
let pop = ShowsView::new(self.sender.clone(), vadj); .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.populated = pop;
self.stack.remove(old); self.stack.remove(old);
self.stack self.stack.add_named(&self.populated.container, "shows");
.add_named(self.populated.view.container(), "shows");
old.destroy(); old.destroy();
Ok(()) Ok(())
} }
pub(crate) fn replace_widget(&mut self, pd: Arc<Show>) -> Result<(), Error> { pub fn replace_widget(&mut self, pd: Arc<Podcast>) -> Result<(), Error> {
let old = self.show.view.container().clone(); let old = self.show.container.clone();
// Get the ShowWidget vertical alignment // save the ShowWidget vertical scrollabar alignment
let vadj = self.show.view.get_vadjustment(); self.show
let new = match self.show.show_id() { .podcast_id()
// If the previous show was the same, restore the alignment .map(|id| self.show.save_vadjustment(id));
Some(id) if id == pd.id() => ShowWidget::new(pd, self.sender.clone(), vadj),
// else leave the valignemnt to default
_ => ShowWidget::new(pd.clone(), self.sender.clone(), None),
};
let new = ShowWidget::new(pd, self.sender.clone());
self.show = new; self.show = new;
self.stack.remove(&old); self.stack.remove(&old);
self.stack.add_named(self.show.view.container(), "widget"); self.stack.add_named(&self.show.container, "widget");
// The current visible child might change depending on // The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have // removal and insertion in the gtk::Stack, so we have
@@ -109,9 +111,9 @@ impl PopulatedStack {
Ok(()) Ok(())
} }
pub(crate) fn update_widget(&mut self) -> Result<(), Error> { pub fn update_widget(&mut self) -> Result<(), Error> {
let old = self.show.view.container().clone(); let old = self.show.container.clone();
let id = self.show.show_id(); let id = self.show.podcast_id();
if id.is_none() { if id.is_none() {
return Ok(()); return Ok(());
} }
@@ -129,9 +131,9 @@ impl PopulatedStack {
Ok(()) Ok(())
} }
// Only update widget if its show_id is equal to pid. // Only update widget if its podcast_id is equal to pid.
pub(crate) fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> { pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
if self.show.show_id() != Some(pid) { if self.show.podcast_id() != Some(pid) {
debug!("Different widget. Early return"); debug!("Different widget. Early return");
return Ok(()); return Ok(());
} }
@@ -139,11 +141,11 @@ impl PopulatedStack {
self.update_widget() self.update_widget()
} }
pub(crate) fn container(&self) -> gtk::Box { pub fn container(&self) -> gtk::Box {
self.container.clone() self.container.clone()
} }
pub(crate) fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) { pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
use self::PopulatedState::*; use self::PopulatedState::*;
match state { match state {
@@ -3,36 +3,40 @@ use gtk::prelude::*;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use failure::Error; use failure::Error;
use podcasts_data::dbqueries::is_podcasts_populated; use hammond_data::dbqueries::is_podcasts_populated;
use app::Action; use app::Action;
use stacks::content::State;
use stacks::PopulatedStack; use stacks::PopulatedStack;
use utils::get_ignored_shows; use utils::get_ignored_shows;
use widgets::EmptyView; use widgets::EmptyView;
use std::cell::RefCell; use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc; use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
pub enum ShowState {
Populated,
Empty,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ShowStack { pub struct ShowStack {
empty: EmptyView, empty: EmptyView,
populated: Rc<RefCell<PopulatedStack>>, populated: Rc<RefCell<PopulatedStack>>,
stack: gtk::Stack, stack: gtk::Stack,
state: State, state: ShowState,
sender: Sender<Action>, sender: Sender<Action>,
} }
impl ShowStack { impl ShowStack {
pub(crate) fn new(sender: Sender<Action>) -> Self { pub fn new(sender: Sender<Action>) -> Result<Self, Error> {
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone()))); let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())?));
let empty = EmptyView::default(); let empty = EmptyView::new();
let stack = gtk::Stack::new(); let stack = gtk::Stack::new();
let state = State::Empty; let state = ShowState::Empty;
stack.add_named(&populated.borrow().container(), "populated"); stack.add_named(&populated.borrow().container(), "populated");
stack.add_named(empty.deref(), "empty"); stack.add_named(&empty.container, "empty");
let mut show = ShowStack { let mut show = ShowStack {
empty, empty,
@@ -42,26 +46,25 @@ impl ShowStack {
sender, sender,
}; };
let res = show.determine_state(); show.determine_state()?;
debug_assert!(res.is_ok()); Ok(show)
show
} }
pub(crate) fn get_stack(&self) -> gtk::Stack { pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone() self.stack.clone()
} }
pub(crate) fn populated(&self) -> Rc<RefCell<PopulatedStack>> { pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
self.populated.clone() self.populated.clone()
} }
pub(crate) fn update(&mut self) -> Result<(), Error> { pub fn update(&mut self) -> Result<(), Error> {
self.populated.borrow_mut().update(); self.populated.borrow_mut().update();
self.determine_state() self.determine_state()
} }
pub(crate) fn switch_visible(&mut self, s: State) { fn switch_visible(&mut self, s: ShowState) {
use self::State::*; use self::ShowState::*;
match s { match s {
Populated => { Populated => {
@@ -76,12 +79,14 @@ impl ShowStack {
} }
fn determine_state(&mut self) -> Result<(), Error> { fn determine_state(&mut self) -> Result<(), Error> {
use self::ShowState::*;
let ign = get_ignored_shows()?; let ign = get_ignored_shows()?;
debug!("IGNORED SHOWS {:?}", ign); debug!("IGNORED SHOWS {:?}", ign);
if is_podcasts_populated(&ign)? { if is_podcasts_populated(&ign)? {
self.sender.send(Action::PopulatedState); self.switch_visible(Populated);
} else { } else {
self.sender.send(Action::EmptyState); self.switch_visible(Empty);
}; };
Ok(()) Ok(())
@@ -1,7 +1,7 @@
use gio::{resources_register, Error, Resource}; use gio::{resources_register, Error, Resource};
use glib::Bytes; use glib::Bytes;
pub(crate) fn init() -> Result<(), Error> { pub fn init() -> Result<(), Error> {
// load the gresource binary at build time and include/link it into the final // load the gresource binary at build time and include/link it into the final
// binary. // binary.
let res_bytes = include_bytes!("../resources/resources.gresource"); let res_bytes = include_bytes!("../resources/resources.gresource");
@@ -10,7 +10,7 @@ pub(crate) fn init() -> Result<(), Error> {
let gbytes = Bytes::from_static(res_bytes.as_ref()); let gbytes = Bytes::from_static(res_bytes.as_ref());
let resource = Resource::new_from_data(&gbytes)?; let resource = Resource::new_from_data(&gbytes)?;
// Register the resource so it won't be dropped and will continue to live in // Register the resource so It wont be dropped and will continue to live in
// memory. // memory.
resources_register(&resource); resources_register(&resource);
@@ -1,36 +1,35 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))] #![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use gdk::FrameClockExt; use gdk::FrameClockExt;
use gdk_pixbuf::{Object, Pixbuf}; use gdk_pixbuf::Pixbuf;
use glib::{self, object::WeakRef}; use glib;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{IsA, Widget}; use gtk::{IsA, Widget};
use chrono::prelude::*; use chrono::prelude::*;
use crossbeam_channel::{bounded, unbounded, Sender}; use crossbeam_channel::Sender;
use failure::Error; use failure::Error;
use fragile::Fragile;
use rayon; use rayon;
use regex::Regex; use regex::Regex;
use reqwest; use reqwest;
use send_cell::SendCell;
use serde_json::Value; use serde_json::Value;
// use podcasts_data::feed; // use hammond_data::feed;
use podcasts_data::dbqueries; use hammond_data::dbqueries;
use podcasts_data::opml; use hammond_data::opml;
use podcasts_data::pipeline; use hammond_data::pipeline;
use podcasts_data::utils::checkup; use hammond_data::utils::checkup;
use podcasts_data::Source; use hammond_data::Source;
use podcasts_downloader::downloader; use hammond_downloader::downloader;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::mpsc::channel;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use app::Action; use app::Action;
use i18n::i18n;
/// Lazy evaluates and loads widgets to the parent `container` widget. /// Lazy evaluates and loads widgets to the parent `container` widget.
/// ///
/// Accepts an `IntoIterator`, `data`, as the source from which each widget /// Accepts an `IntoIterator`, `data`, as the source from which each widget
@@ -64,25 +63,16 @@ use i18n::i18n;
/// let list = gtk::ListBox::new(); /// let list = gtk::ListBox::new();
/// lazy_load(widgets, list, |w| w, || {}); /// lazy_load(widgets, list, |w| w, || {});
/// ``` /// ```
pub(crate) fn lazy_load<T, C, F, W, U>( pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
data: T, where
container: WeakRef<C>,
mut contructor: F,
callback: U,
) where
T: IntoIterator + 'static, T: IntoIterator + 'static,
T::Item: 'static, T::Item: 'static,
C: IsA<Object> + ContainerExt + 'static, C: ContainerExt + 'static,
F: FnMut(T::Item) -> W + 'static, F: FnMut(T::Item) -> W + 'static,
W: IsA<Widget> + WidgetExt, W: IsA<Widget> + WidgetExt,
U: Fn() + 'static, U: Fn() + 'static,
{ {
let func = move |x| { let func = move |x| {
let container = match container.upgrade() {
Some(c) => c,
None => return,
};
let widget = contructor(x); let widget = contructor(x);
container.add(&widget); container.add(&widget);
widget.show(); widget.show();
@@ -97,7 +87,7 @@ pub(crate) fn lazy_load<T, C, F, W, U>(
/// If you just want to lazy add `widgets` to a `container` check if /// If you just want to lazy add `widgets` to a `container` check if
/// `lazy_load` fits your needs first. /// `lazy_load` fits your needs first.
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))] #[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
pub(crate) fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U) pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
where where
T: IntoIterator + 'static, T: IntoIterator + 'static,
T::Item: 'static, T::Item: 'static,
@@ -119,7 +109,7 @@ where
// Kudos to Julian Sparber // Kudos to Julian Sparber
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/ // https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))] #[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
pub(crate) fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::Adjustment) { pub fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::Adjustment) {
if let Some(adj) = view.get_vadjustment() { if let Some(adj) = view.get_vadjustment() {
if let Some(clock) = view.get_frame_clock() { if let Some(clock) = view.get_frame_clock() {
let duration = 200; let duration = 200;
@@ -156,127 +146,105 @@ lazy_static! {
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new())); static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
} }
pub(crate) fn ignore_show(id: i32) -> Result<bool, Error> { pub fn ignore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS IGNORESHOWS
.lock() .lock()
.map(|mut guard| guard.insert(id)) .map(|mut guard| guard.insert(id))
.map_err(|err| format_err!("{}", err)) .map_err(|err| format_err!("{}", err))
} }
pub(crate) fn uningore_show(id: i32) -> Result<bool, Error> { pub fn uningore_show(id: i32) -> Result<bool, Error> {
IGNORESHOWS IGNORESHOWS
.lock() .lock()
.map(|mut guard| guard.remove(&id)) .map(|mut guard| guard.remove(&id))
.map_err(|err| format_err!("{}", err)) .map_err(|err| format_err!("{}", err))
} }
pub(crate) fn get_ignored_shows() -> Result<Vec<i32>, Error> { pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
IGNORESHOWS IGNORESHOWS
.lock() .lock()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>()) .map(|guard| guard.iter().cloned().collect::<Vec<_>>())
.map_err(|err| format_err!("{}", err)) .map_err(|err| format_err!("{}", err))
} }
pub(crate) fn cleanup(cleanup_date: DateTime<Utc>) { pub fn cleanup(cleanup_date: DateTime<Utc>) {
checkup(cleanup_date) checkup(cleanup_date)
.map_err(|err| error!("Check up failed: {}", err)) .map_err(|err| error!("Check up failed: {}", err))
.ok(); .ok();
} }
pub(crate) fn refresh<S>(source: Option<S>, sender: Sender<Action>) pub fn refresh<S>(source: Option<S>, sender: Sender<Action>)
where where
S: IntoIterator<Item = Source> + Send + 'static, S: IntoIterator<Item = Source> + Send + 'static,
{ {
// If we try to update the whole db,
// Exit early if `source` table is empty
if source.is_none() {
match dbqueries::is_source_populated(&[]) {
Ok(false) => {
info!("No source of feeds where found, returning");
return;
}
Err(err) => debug_assert!(false, err),
_ => (),
};
}
refresh_feed(source, sender) refresh_feed(source, sender)
.map_err(|err| error!("Failed to update feeds: {}", err))
.ok();
} }
/// Update the rss feed(s) originating from `source`. /// Update the rss feed(s) originating from `source`.
/// If `source` is None, Fetches all the `Source` entries in the database and updates them. /// If `source` is None, Fetches all the `Source` entries in the database and updates them.
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) /// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
where where
S: IntoIterator<Item = Source> + Send + 'static, S: IntoIterator<Item = Source> + Send + 'static,
{ {
rayon::spawn(move || { sender
let (up_sender, up_receiver) = bounded(1); .send(Action::HeaderBarShowUpdateIndicator)
sender.send(Action::ShowUpdateNotif(up_receiver)); .map_err(|err| error!("Action Sender: {}", err))
.ok();
rayon::spawn(move || {
if let Some(s) = source { if let Some(s) = source {
// Refresh only specified feeds // Refresh only specified feeds
pipeline::run(s) pipeline::run(s, false)
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Error while trying to update the database.")) .map_err(|_| error!("Error While trying to update the database."))
.ok(); .ok();
} else { } else {
// Refresh all the feeds // Refresh all the feeds
dbqueries::get_sources() dbqueries::get_sources()
.map(|s| s.into_iter()) .map(|s| s.into_iter())
.and_then(pipeline::run) .and_then(|s| pipeline::run(s, false))
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.ok(); .ok();
}; };
up_sender.send(true); sender
.send(Action::HeaderBarHideUpdateIndicator)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
sender
.send(Action::RefreshAllViews)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}); });
Ok(())
} }
lazy_static! { lazy_static! {
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<Fragile<Pixbuf>>>> = static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
{ RwLock::new(HashMap::new()) }; { RwLock::new(HashMap::new()) };
static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new()); static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap(); static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
} }
// Since gdk_pixbuf::Pixbuf is reference counted and every episode, // Since gdk_pixbuf::Pixbuf is refference counted and every episode,
// use the cover of the Podcast Feed/Show, We can only create a Pixbuf // use the cover of the Podcast Feed/Show, We can only create a Pixbuf
// cover per show and pass around the Rc pointer. // cover per show and pass around the Rc pointer.
// //
// GObjects do not implement Send trait, so SendCell is a way around that. // GObjects do not implement Send trait, so SendCell is a way around that.
// Also lazy_static requires Sync trait, so that's what the mutexes are. // Also lazy_static requires Sync trait, so that's what the mutexes are.
// TODO: maybe use something that would just scale to requested size? // TODO: maybe use something that would just scale to requested size?
pub(crate) fn set_image_from_path( pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Result<(), Error> {
image: &gtk::Image,
show_id: i32,
size: u32,
) -> Result<(), Error> {
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
// Check if the requested (cover + size) is already in the cache
// and if so do an early return after that.
if let Some(guard) = hashmap.get(&(show_id, size)) {
guard
.lock()
.map_err(|err| format_err!("Fragile Mutex: {}", err))
.and_then(|fragile| {
fragile
.try_get()
.map(|px| image.set_from_pixbuf(px))
.map_err(From::from)
})?;
return Ok(());
}
}
// Check if there's an active download about this show cover. // 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 there is, a callback will be set so this function will be called again.
// If the download succeeds, there should be a quick return from the pixbuf cache_image // If the download succedes, there should be a quick return from the pixbuf cache_image
// If it fails another download will be scheduled. // If it fails another download will be scheduled.
if let Ok(guard) = COVER_DL_REGISTRY.read() { if let Ok(guard) = COVER_DL_REGISTRY.read() {
if guard.contains(&show_id) { if guard.contains(&podcast_id) {
let callback = clone!(image => move || { let callback = clone!(image => move || {
let _ = set_image_from_path(&image, show_id, size); let _ = set_image_from_path(&image, podcast_id, size);
glib::Continue(false) glib::Continue(false)
}); });
gtk::timeout_add(250, callback); gtk::timeout_add(250, callback);
@@ -284,32 +252,50 @@ pub(crate) fn set_image_from_path(
} }
} }
let (sender, receiver) = unbounded(); if let Ok(hashmap) = CACHED_PIXBUFS.read() {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() { // Check if the requested (cover + size) is already in the chache
// Add the id to the hashmap from the main thread to avoid queuing more than one downloads. // and if so do an early return after that.
guard.insert(show_id); if let Some(guard) = hashmap.get(&(podcast_id, size)) {
drop(guard); guard
.lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err))
.and_then(|sendcell| {
sendcell
.try_get()
.map(|px| image.set_from_pixbuf(px))
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
})?;
THREADPOOL.spawn(move || { return Ok(());
// This operation is polling and will block the thread till the download is finished }
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(show_id) {
sender.send(downloader::cache_image(&pd));
}
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&show_id);
}
});
} }
let (sender, receiver) = channel();
THREADPOOL.spawn(move || {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.insert(podcast_id);
}
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(podcast_id) {
sender
.send(downloader::cache_image(&pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&podcast_id);
}
});
let image = image.clone(); let image = image.clone();
let s = size as i32; let s = size as i32;
gtk::timeout_add(25, move || { gtk::timeout_add(25, move || {
if let Some(path) = receiver.try_recv() { if let Ok(path) = receiver.try_recv() {
if let Ok(path) = path { if let Ok(path) = path {
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) { if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() { if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
hashmap.insert((show_id, size), Mutex::new(Fragile::new(px.clone()))); hashmap.insert((podcast_id, size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px); image.set_from_pixbuf(&px);
} }
} }
@@ -323,8 +309,8 @@ pub(crate) fn set_image_from_path(
} }
// FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>` // FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>`
pub(crate) fn itunes_to_rss(url: &str) -> Result<String, 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."))?; let id = itunes_id_from_url(url).ok_or_else(|| format_err!("Failed to find an Itunes ID."))?;
lookup_id(id) lookup_id(id)
} }
@@ -348,16 +334,18 @@ fn lookup_id(id: u32) -> Result<String, Error> {
.ok_or_else(|| format_err!("Failed to get url from itunes response")) .ok_or_else(|| format_err!("Failed to get url from itunes response"))
} }
pub(crate) fn on_import_clicked(window: &gtk::ApplicationWindow, sender: &Sender<Action>) { pub fn on_import_clicked(window: &gtk::Window, sender: &Sender<Action>) {
use glib::translate::ToGlib; use glib::translate::ToGlib;
use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType}; 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 // Create the FileChooser Dialog
let dialog = FileChooserNative::new( let dialog = FileChooserNative::new(
Some(i18n("Select the file from which to you want to import shows.").as_str()), Some("Select the file from which to you want to Import Shows."),
Some(window), Some(window),
FileChooserAction::Open, FileChooserAction::Open,
Some(i18n("_Import").as_str()), Some("_Import"),
None, None,
); );
@@ -366,46 +354,55 @@ pub(crate) fn on_import_clicked(window: &gtk::ApplicationWindow, sender: &Sender
// Set a filter to show only xml files // Set a filter to show only xml files
let filter = FileFilter::new(); let filter = FileFilter::new();
FileFilterExt::set_name(&filter, Some(i18n("OPML file").as_str())); FileFilterExt::set_name(&filter, Some("OPML file"));
filter.add_mime_type("application/xml"); filter.add_mime_type("application/xml");
filter.add_mime_type("text/xml"); filter.add_mime_type("text/xml");
dialog.add_filter(&filter); dialog.add_filter(&filter);
let resp = dialog.run(); dialog.connect_response(clone!(sender => move |dialog, resp| {
debug!("Dialog Response {}", resp); debug!("Dialong Response {}", resp);
if resp == ResponseType::Accept.to_glib() { if resp == ResponseType::Accept.to_glib() {
if let Some(filename) = dialog.get_filename() { // TODO: Show an in-app notifictaion if the file can not be accessed
debug!("File selected: {:?}", filename); if let Some(filename) = dialog.get_filename() {
debug!("File selected: {:?}", filename);
rayon::spawn(clone!(sender => move || { rayon::spawn(clone!(sender => move || {
// Parse the file and import the feeds // Parse the file and import the feeds
if let Ok(sources) = opml::import_from_file(filename) { if let Ok(sources) = opml::import_from_file(filename) {
// Refresh the successfully parsed feeds to index them // Refresh the succesfully parsed feeds to index them
refresh(Some(sources), sender) refresh(Some(sources), sender)
} else { } else {
let text = i18n("Failed to parse the imported file"); let text = String::from("Failed to parse the Imported file");
sender.send(Action::ErrorNotification(text)); sender.send(Action::ErrorNotification(text))
} .map_err(|err| error!("Action Sender: {}", err))
})) .ok();
} else { }
let text = i18n("Selected file could not be accessed."); }))
sender.send(Action::ErrorNotification(text)); } else {
let text = String::from("Selected File could not be accessed.");
sender.send(Action::ErrorNotification(text))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
} }
}
dialog.destroy();
}));
dialog.run();
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use failure::Error; // use hammond_data::Source;
// use podcasts_data::Source; // use hammond_data::dbqueries;
// use podcasts_data::dbqueries;
// #[test] // #[test]
// This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit // This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it. // to run it.
// #[ignore] // #[ignore]
// Disabled till https://gitlab.gnome.org/World/podcasts/issues/56 // Disabled till https://gitlab.gnome.org/World/hammond/issues/56
// fn test_set_image_from_path() { // fn test_set_image_from_path() {
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff"; // let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source // Create and index a source
@@ -422,32 +419,29 @@ mod tests {
// } // }
#[test] #[test]
fn test_itunes_to_rss() -> Result<(), Error> { fn test_itunes_to_rss() {
let itunes_url = "https://itunes.apple.com/podcast/id1195206601"; let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill"); let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
assert_eq!(rss_url, itunes_to_rss(itunes_url)?); assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000"; let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
assert!(itunes_to_rss(itunes_url).is_err()); assert!(itunes_to_rss(itunes_url).is_err());
Ok(())
} }
#[test] #[test]
fn test_itunes_id() -> Result<(), Error> { fn test_itunes_id() {
let id = 1195206601; let id = 1195206601;
let itunes_url = "https://itunes.apple.com/podcast/id1195206601"; let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
assert_eq!(id, itunes_id_from_url(itunes_url).unwrap()); assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
Ok(())
} }
#[test] #[test]
fn test_itunes_lookup_id() -> Result<(), Error> { fn test_itunes_lookup_id() {
let id = 1195206601; let id = 1195206601;
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill"; let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
assert_eq!(rss_url, lookup_id(id)?); assert_eq!(rss_url, lookup_id(id).unwrap());
let id = 000000000; let id = 000000000;
assert!(lookup_id(id).is_err()); assert!(lookup_id(id).is_err());
Ok(())
} }
} }
@@ -1,19 +1,14 @@
use app::{APP_ID, VERSION};
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use i18n::i18n;
// Totally copied it from fractal. // Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912 // 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. /// Given a `window` create and attach an `gtk::AboutDialog` to it.
pub(crate) fn about_dialog(window: &gtk::ApplicationWindow) { pub fn about_dialog(window: &gtk::Window) {
// Feel free to add yourself if you contributed. // Feel free to add yourself if you contribured.
let authors = &[ let authors = &[
"Carlos Soriano",
"Constantin Nickel", "Constantin Nickel",
"Gabriele Musco", "Gabriele Musco",
"Ivan Augusto",
"James Wykeham-Martin", "James Wykeham-Martin",
"Jordan Petridis", "Jordan Petridis",
"Julian Sparber", "Julian Sparber",
@@ -22,20 +17,24 @@ pub(crate) fn about_dialog(window: &gtk::ApplicationWindow) {
]; ];
let dialog = gtk::AboutDialog::new(); let dialog = gtk::AboutDialog::new();
dialog.set_logo_icon_name(APP_ID); // Waiting for a logo.
dialog.set_comments(i18n("Podcast Client for the GNOME Desktop.").as_str()); // 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_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30); dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true); dialog.set_modal(true);
dialog.set_version(VERSION); // TODO: make it show it fetches the commit hash from which it was built
dialog.set_program_name(&i18n("Podcasts")); // and the version number is kept in sync automaticly
dialog.set_website("https://wiki.gnome.org/Apps/Podcasts"); dialog.set_version("0.3.4");
dialog.set_website_label(i18n("Learn more about GNOME Podcasts").as_str()); 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_transient_for(window);
dialog.set_artists(&["Tobias Bernard", "Sam Hewitt"]); dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors); dialog.set_authors(authors);
dialog.set_translator_credits(i18n("translator-credits").as_str());
dialog.connect_response(|dlg, _| dlg.destroy()); dialog.connect_response(|dlg, _| dlg.destroy());
+94
View File
@@ -0,0 +1,94 @@
use glib;
use gtk;
use gtk::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone, Copy)]
pub enum UndoState {
Shown,
Hidden,
}
#[derive(Debug, Clone)]
pub struct InAppNotification {
revealer: gtk::Revealer,
text: gtk::Label,
undo: gtk::Button,
close: gtk::Button,
}
impl Default for InAppNotification {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/inapp_notif.ui");
let revealer: gtk::Revealer = builder.get_object("revealer").unwrap();
let text: gtk::Label = builder.get_object("text").unwrap();
let undo: gtk::Button = builder.get_object("undo").unwrap();
let close: gtk::Button = builder.get_object("close").unwrap();
InAppNotification {
revealer,
text,
undo,
close,
}
}
}
impl InAppNotification {
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self
where
F: FnMut() -> glib::Continue + 'static,
U: Fn() + 'static,
{
let notif = InAppNotification::default();
notif.text.set_text(&text);
let revealer = notif.revealer.clone();
let id = timeout_add_seconds(6, move || {
revealer.set_reveal_child(false);
callback()
});
let id = Rc::new(RefCell::new(Some(id)));
// Cancel the callback
let revealer = notif.revealer.clone();
notif.undo.connect_clicked(move |_| {
let foo = id.borrow_mut().take();
if let Some(id) = foo {
glib::source::source_remove(id);
}
undo_callback();
// Hide the notification
revealer.set_reveal_child(false);
});
// Hide the revealer when the close button is clicked
let revealer = notif.revealer.clone();
notif.close.connect_clicked(move |_| {
revealer.set_reveal_child(false);
});
match show_undo {
UndoState::Shown => (),
UndoState::Hidden => notif.undo.hide(),
}
notif
}
// This is a seperate method cause in order to get a nice animation
// the revealer should be attached to something that displays it.
// Previously we where doing it in the constructor, which had the result
// of the animation being skipped cause there was no parent widget to display it.
pub fn show(&self, overlay: &gtk::Overlay) {
overlay.add_overlay(&self.revealer);
// We need to display the notification after the widget is added to the overlay
// so there will be a nice animation.
self.revealer.set_reveal_child(true);
}
}
+21
View File
@@ -0,0 +1,21 @@
use gtk;
#[derive(Debug, Clone)]
pub struct EmptyView {
pub container: gtk::Box,
}
impl Default for EmptyView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_view.ui");
let view: gtk::Box = builder.get_object("empty_view").unwrap();
EmptyView { container: view }
}
}
impl EmptyView {
pub fn new() -> EmptyView {
EmptyView::default()
}
}
@@ -7,22 +7,19 @@ use chrono::prelude::*;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use failure::Error; use failure::Error;
use humansize::{file_size_opts as size_opts, FileSize}; use humansize::{file_size_opts as size_opts, FileSize};
#[allow(unused_imports)]
use open; use open;
use podcasts_data::dbqueries; use hammond_data::dbqueries;
use podcasts_data::utils::get_download_folder; use hammond_data::utils::get_download_folder;
use podcasts_data::EpisodeWidgetModel; use hammond_data::EpisodeWidgetQuery;
use app::Action; use app::Action;
use manager; use manager;
use std::cell::RefCell; use std::path::Path;
use std::rc::{Rc, Weak}; use std::rc::Rc;
use std::sync::{Arc, Mutex, TryLockError}; use std::sync::{Arc, Mutex, TryLockError};
use i18n::i18n_f;
lazy_static! { lazy_static! {
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = { static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct // Declare a custom humansize option struct
@@ -42,8 +39,8 @@ lazy_static! {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct EpisodeWidget { pub struct EpisodeWidget {
pub(crate) container: gtk::Box, pub container: gtk::Box,
info: InfoLabels, info: InfoLabels,
buttons: Buttons, buttons: Buttons,
progressbar: gtk::ProgressBar, progressbar: gtk::ProgressBar,
@@ -68,24 +65,25 @@ struct Buttons {
play: gtk::Button, play: gtk::Button,
download: gtk::Button, download: gtk::Button,
cancel: gtk::Button, cancel: gtk::Button,
desc: gtk::Button,
} }
impl InfoLabels { impl InfoLabels {
fn init(&self, episode: &EpisodeWidgetModel) { fn init(&self, episode: &EpisodeWidgetQuery) {
// Set the title label state. // Set the title label state.
self.set_title(episode); self.set_title(episode);
// Set the date label. // Set the date label.
self.set_date(episode.epoch()); self.set_date(episode.epoch());
// Set the duration label. // Set the duaration label.
self.set_duration(episode.duration()); self.set_duration(episode.duration());
// Set the total_size label. // Set the total_size label.
self.set_size(episode.length()) self.set_size(episode.length())
} }
fn set_title(&self, episode: &EpisodeWidgetModel) { fn set_title(&self, episode: &EpisodeWidgetQuery) {
self.title.set_text(episode.title()); self.title.set_text(episode.title());
if episode.played().is_some() { if episode.played().is_some() {
@@ -118,15 +116,14 @@ impl InfoLabels {
// Set the duration label of the episode widget. // Set the duration label of the episode widget.
fn set_duration(&self, seconds: Option<i32>) { fn set_duration(&self, seconds: Option<i32>) {
// If length is provided // If lenght is provided
if let Some(s) = seconds { if let Some(s) = seconds {
// Convert seconds to minutes // Convert seconds to minutes
let minutes = chrono::Duration::seconds(s.into()).num_minutes(); let minutes = chrono::Duration::seconds(s.into()).num_minutes();
// If the length is 1 or more minutes // If the lenght is 1 or more minutes
if minutes != 0 { if minutes != 0 {
// Set the label and show them. // Set the label and show them.
self.duration self.duration.set_text(&format!("{} min", minutes));
.set_text(&i18n_f("{} min", &[&minutes.to_string()]));
self.duration.show(); self.duration.show();
self.separator1.show(); self.separator1.show();
return; return;
@@ -163,7 +160,7 @@ impl InfoLabels {
impl Default for EpisodeWidget { impl Default for EpisodeWidget {
fn default() -> Self { fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/episode_widget.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episode_widget.ui");
let container = builder.get_object("episode_container").unwrap(); let container = builder.get_object("episode_container").unwrap();
let progressbar = builder.get_object("progress_bar").unwrap(); let progressbar = builder.get_object("progress_bar").unwrap();
@@ -172,6 +169,7 @@ impl Default for EpisodeWidget {
let download = builder.get_object("download_button").unwrap(); let download = builder.get_object("download_button").unwrap();
let play = builder.get_object("play_button").unwrap(); let play = builder.get_object("play_button").unwrap();
let cancel = builder.get_object("cancel_button").unwrap(); let cancel = builder.get_object("cancel_button").unwrap();
let desc_button = builder.get_object("desc_button").unwrap();
let info_container = builder.get_object("info_container").unwrap(); let info_container = builder.get_object("info_container").unwrap();
let title = builder.get_object("title_label").unwrap(); let title = builder.get_object("title_label").unwrap();
@@ -202,6 +200,7 @@ impl Default for EpisodeWidget {
play, play,
download, download,
cancel, cancel,
desc: desc_button,
}, },
progressbar, progressbar,
container, container,
@@ -210,38 +209,39 @@ impl Default for EpisodeWidget {
} }
impl EpisodeWidget { impl EpisodeWidget {
pub(crate) fn new(episode: EpisodeWidgetModel, sender: &Sender<Action>) -> Rc<Self> { pub fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Rc<Self> {
let widget = Rc::new(Self::default()); let widget = Rc::new(Self::default());
let episode = RefCell::new(Some(episode)); widget.info.init(episode);
let weak = Rc::downgrade(&widget); Self::determine_buttons_state(&widget, episode, sender)
widget.container.connect_draw(clone!(sender => move |_, _| { .map_err(|err| error!("Error: {}", err))
episode.borrow_mut().take().map(|ep| { .ok();
weak.upgrade().map(|w| w.info.init(&ep));
Self::determine_buttons_state(&weak, &ep, &sender)
.map_err(|err| error!("Error: {}", err))
.ok();
});
gtk::Inhibit(false)
}));
// When the widget is attached to a parent,
// since it's a rust struct and not a widget the
// compiler drops the reference to it at the end of
// scope. That's cause we only attach the `self.container`
// to the parent.
//
// So this callback keeps a reference to the Rust Struct
// so the compiler won't drop it.
//
// When the widget is detached from its parent view this
// callback runs freeing the last reference we were holding.
let foo = RefCell::new(Some(widget.clone()));
widget.container.connect_remove(move |_, _| {
foo.borrow_mut().take();
});
widget widget
.on_desc_clicked(episode, sender)
.map_err(|err| error!("Error: {}", err))
.ok();
widget
}
fn on_desc_clicked(
&self,
episode: &EpisodeWidgetQuery,
sender: &Sender<Action>,
) -> Result<(), Error> {
let id = episode.rowid();
if dbqueries::episode_had_description(id)? {
self.buttons.desc.show();
self.buttons
.desc
.connect_clicked(clone!(sender => move |_| {
sender.send(Action::AboutWidget(id))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}));
Ok(())
} else {
self.buttons.desc.hide();
Ok(())
}
} }
// fn init(widget: Rc<Self>, sender: &Sender<Action>) {} // fn init(widget: Rc<Self>, sender: &Sender<Action>) {}
@@ -325,13 +325,10 @@ impl EpisodeWidget {
/// | ToDownload | /// | ToDownload |
/// ------------------- /// -------------------
fn determine_buttons_state( fn determine_buttons_state(
weak: &Weak<Self>, widget: &Rc<Self>,
episode: &EpisodeWidgetModel, episode: &EpisodeWidgetQuery,
sender: &Sender<Action>, sender: &Sender<Action>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let widget = weak
.upgrade()
.ok_or_else(|| format_err!("Widget is already dropped"))?;
// Reset the buttons state no matter the glade file. // Reset the buttons state no matter the glade file.
// This is just to make it easier to port to relm in the future. // This is just to make it easier to port to relm in the future.
widget.buttons.cancel.hide(); widget.buttons.cancel.hide();
@@ -348,14 +345,14 @@ impl EpisodeWidget {
Ok(m.get(&id).cloned()) Ok(m.get(&id).cloned())
}; };
// State: InProgress // State: InProggress
if let Some(prog) = active_dl()? { if let Some(prog) = active_dl()? {
// set a callback that will update the state when the download finishes // set a callback that will update the state when the download finishes
let callback = clone!(weak, sender => move || { let callback = clone!(widget, sender => move || {
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() { if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() {
if !guard.contains_key(&id) { if !guard.contains_key(&id) {
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) { if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
Self::determine_buttons_state(&weak, &ep, &sender) Self::determine_buttons_state(&widget, &ep, &sender)
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.ok(); .ok();
@@ -372,20 +369,20 @@ impl EpisodeWidget {
widget widget
.buttons .buttons
.cancel .cancel
.connect_clicked(clone!(prog, weak, sender => move |_| { .connect_clicked(clone!(prog, widget, sender => move |_| {
// Cancel the download // Cancel the download
if let Ok(mut m) = prog.lock() { if let Ok(mut m) = prog.lock() {
m.cancel(); m.cancel();
} }
// Cancel is not instant so we have to wait a bit // Cancel is not instant so we have to wait a bit
timeout_add(50, clone!(weak, sender => move || { timeout_add(50, clone!(widget, sender => move || {
if let Ok(thing) = active_dl() { if let Ok(thing) = active_dl() {
if thing.is_none() { if thing.is_none() {
// Recalculate the widget state // Recalculate the widget state
dbqueries::get_episode_widget_from_rowid(id) dbqueries::get_episode_widget_from_rowid(id)
.map_err(From::from) .map_err(From::from)
.and_then(|ep| Self::determine_buttons_state(&weak, &ep, &sender)) .and_then(|ep| Self::determine_buttons_state(&widget, &ep, &sender))
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.ok(); .ok();
@@ -400,10 +397,10 @@ impl EpisodeWidget {
// Setup a callback that will update the total_size label // Setup a callback that will update the total_size label
// with the http ContentLength header number rather than // with the http ContentLength header number rather than
// relying to the RSS feed. // relying to the RSS feed.
update_total_size_callback(&weak, &prog); update_total_size_callback(&widget, &prog);
// Setup a callback that will update the progress bar. // Setup a callback that will update the progress bar.
update_progressbar_callback(&weak, &prog, id); update_progressbar_callback(&widget, &prog, id);
// Change the widget layout/state // Change the widget layout/state
widget.state_prog(); widget.state_prog();
@@ -420,9 +417,9 @@ impl EpisodeWidget {
widget widget
.buttons .buttons
.play .play
.connect_clicked(clone!(weak, sender => move |_| { .connect_clicked(clone!(widget, sender => move |_| {
if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) { if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_play_bttn_clicked(&weak, &mut ep, &sender) on_play_bttn_clicked(&widget, &mut ep, &sender)
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.ok(); .ok();
} }
@@ -436,14 +433,14 @@ impl EpisodeWidget {
widget widget
.buttons .buttons
.download .download
.connect_clicked(clone!(weak, sender => move |dl| { .connect_clicked(clone!(widget, sender => move |dl| {
// Make the button insensitive so it won't be pressed twice // Make the button insensitive so it won't be pressed twice
dl.set_sensitive(false); dl.set_sensitive(false);
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) { if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
on_download_clicked(&ep, &sender) on_download_clicked(&ep, &sender)
.and_then(|_| { .and_then(|_| {
info!("Download started successfully."); info!("Donwload started succesfully.");
Self::determine_buttons_state(&weak, &ep, &sender) Self::determine_buttons_state(&widget, &ep, &sender)
}) })
.map_err(|err| error!("Error: {}", err)) .map_err(|err| error!("Error: {}", err))
.ok(); .ok();
@@ -460,36 +457,44 @@ impl EpisodeWidget {
} }
} }
fn on_download_clicked(ep: &EpisodeWidgetModel, sender: &Sender<Action>) -> Result<(), Error> { fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.show_id())?; let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
let download_fold = get_download_folder(&pd.title())?; let download_fold = get_download_folder(&pd.title())?;
// Start a new download. // Start a new download.
manager::add(ep.rowid(), download_fold)?; manager::add(ep.rowid(), download_fold)?;
// Update Views // Update Views
sender.send(Action::RefreshEpisodesViewBGR); sender
Ok(()) .send(Action::RefreshEpisodesViewBGR)
.map_err(From::from)
} }
fn on_play_bttn_clicked( fn on_play_bttn_clicked(
widget: &Weak<EpisodeWidget>, widget: &Rc<EpisodeWidget>,
episode: &mut EpisodeWidgetModel, episode: &mut EpisodeWidgetQuery,
sender: &Sender<Action>, sender: &Sender<Action>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let widget = widget open_uri(episode.rowid())?;
.upgrade()
.ok_or_else(|| format_err!("Widget is already dropped"))?;
// Mark played
episode.set_played_now()?; episode.set_played_now()?;
// Grey out the title
widget.info.set_title(&episode);
// Play the episode widget.info.set_title(&episode);
sender.send(Action::InitEpisode(episode.rowid())); sender
// Refresh background views to match the normal/greyout title state .send(Action::RefreshEpisodesViewBGR)
sender.send(Action::RefreshEpisodesViewBGR); .map_err(From::from)
}
fn open_uri(rowid: i32) -> Result<(), Error> {
let uri = dbqueries::get_episode_local_uri_from_id(rowid)?
.ok_or_else(|| format_err!("Expected Some found None."))?;
if Path::new(&uri).exists() {
info!("Opening {}", uri);
open::that(&uri)?;
} else {
bail!("File \"{}\" does not exist.", uri);
}
Ok(()) Ok(())
} }
@@ -497,7 +502,7 @@ fn on_play_bttn_clicked(
#[inline] #[inline]
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))] #[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
fn update_progressbar_callback( fn update_progressbar_callback(
widget: &Weak<EpisodeWidget>, widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>, prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32, episode_rowid: i32,
) { ) {
@@ -510,15 +515,10 @@ fn update_progressbar_callback(
#[allow(if_same_then_else)] #[allow(if_same_then_else)]
fn progress_bar_helper( fn progress_bar_helper(
widget: &Weak<EpisodeWidget>, widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>, prog: &Arc<Mutex<manager::Progress>>,
episode_rowid: i32, episode_rowid: i32,
) -> Result<glib::Continue, Error> { ) -> Result<glib::Continue, Error> {
let widget = match widget.upgrade() {
Some(w) => w,
None => return Ok(glib::Continue(false)),
};
let (fraction, downloaded) = match prog.try_lock() { let (fraction, downloaded) = match prog.try_lock() {
Ok(guard) => (guard.get_fraction(), guard.get_downloaded()), Ok(guard) => (guard.get_fraction(), guard.get_downloaded()),
Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)), Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)),
@@ -558,7 +558,7 @@ fn progress_bar_helper(
// with the http ContentLength header number rather than // with the http ContentLength header number rather than
// relying to the RSS feed. // relying to the RSS feed.
#[inline] #[inline]
fn update_total_size_callback(widget: &Weak<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) { fn update_total_size_callback(widget: &Rc<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) {
let callback = clone!(prog, widget => move || { let callback = clone!(prog, widget => move || {
total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true)) total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true))
}); });
@@ -566,14 +566,9 @@ fn update_total_size_callback(widget: &Weak<EpisodeWidget>, prog: &Arc<Mutex<man
} }
fn total_size_helper( fn total_size_helper(
widget: &Weak<EpisodeWidget>, widget: &Rc<EpisodeWidget>,
prog: &Arc<Mutex<manager::Progress>>, prog: &Arc<Mutex<manager::Progress>>,
) -> Result<glib::Continue, Error> { ) -> Result<glib::Continue, Error> {
let widget = match widget.upgrade() {
Some(w) => w,
None => return Ok(glib::Continue(false)),
};
// Get the total_bytes. // Get the total_bytes.
let total_bytes = match prog.try_lock() { let total_bytes = match prog.try_lock() {
Ok(guard) => guard.get_total_size(), Ok(guard) => guard.get_total_size(),
@@ -1,19 +1,25 @@
use chrono::prelude::*; use chrono::prelude::*;
use failure::Error; use failure::Error;
use gtk::{self, prelude::*, Adjustment}; use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use libhandy::{Column, ColumnExt}; use hammond_data::dbqueries;
use podcasts_data::dbqueries; use hammond_data::EpisodeWidgetQuery;
use podcasts_data::EpisodeWidgetModel; use send_cell::SendCell;
use app::Action; use app::Action;
use utils::{self, lazy_load_full}; use utils::{self, lazy_load_full};
use widgets::{BaseView, EpisodeWidget}; use widgets::EpisodeWidget;
use std::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Mutex;
lazy_static! {
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
Mutex::new(None);
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum ListSplit { enum ListSplit {
@@ -25,8 +31,9 @@ enum ListSplit {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct HomeView { pub struct HomeView {
pub(crate) view: BaseView, pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box, frame_parent: gtk::Box,
today_box: gtk::Box, today_box: gtk::Box,
yday_box: gtk::Box, yday_box: gtk::Box,
@@ -42,8 +49,9 @@ pub(crate) struct HomeView {
impl Default for HomeView { impl Default for HomeView {
fn default() -> Self { fn default() -> Self {
let view = BaseView::default(); let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view.ui");
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/home_view.ui"); let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap(); let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
let today_box: gtk::Box = builder.get_object("today_box").unwrap(); let today_box: gtk::Box = builder.get_object("today_box").unwrap();
let yday_box: gtk::Box = builder.get_object("yday_box").unwrap(); let yday_box: gtk::Box = builder.get_object("yday_box").unwrap();
@@ -56,19 +64,9 @@ impl Default for HomeView {
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap(); let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap(); let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
let column = Column::new();
column.show();
column.set_maximum_width(700);
// For some reason the Column is not seen as a gtk::container
// and therefore we can't call add() without the cast
let column = column.upcast::<gtk::Widget>();
let column = column.downcast::<gtk::Container>().unwrap();
column.add(&frame_parent);
view.add(&column);
HomeView { HomeView {
view, container,
scrolled_window,
frame_parent, frame_parent,
today_box, today_box,
yday_box, yday_box,
@@ -86,51 +84,69 @@ impl Default for HomeView {
// TODO: REFACTOR ME // TODO: REFACTOR ME
impl HomeView { impl HomeView {
pub(crate) fn new( pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
sender: Sender<Action>,
vadj: Option<Adjustment>,
) -> Result<Rc<HomeView>, Error> {
use self::ListSplit::*; use self::ListSplit::*;
let home = Rc::new(HomeView::default()); let view = Rc::new(HomeView::default());
let ignore = utils::get_ignored_shows()?; let ignore = utils::get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?; let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
let now_utc = Utc::now(); let now_utc = Utc::now();
let home_weak = Rc::downgrade(&home); let view_ = view.clone();
let func = move |ep: EpisodeWidgetModel| { let func = move |ep: EpisodeWidgetQuery| {
let home = match home_weak.upgrade() {
Some(h) => h,
None => return,
};
let epoch = ep.epoch(); let epoch = ep.epoch();
let widget = HomeEpisode::new(ep, &sender); let widget = HomeEpisode::new(&ep, &sender);
match split(&now_utc, i64::from(epoch)) { match split(&now_utc, i64::from(epoch)) {
Today => add_to_box(&widget, &home.today_list, &home.today_box), Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
Yday => add_to_box(&widget, &home.yday_list, &home.yday_box), Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
Week => add_to_box(&widget, &home.week_list, &home.week_box), Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
Month => add_to_box(&widget, &home.month_list, &home.month_box), Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
Rest => add_to_box(&widget, &home.rest_list, &home.rest_box), Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
} }
}; };
let home_weak = Rc::downgrade(&home); let view_ = view.clone();
let callback = move || { let callback = move || {
let home = match home_weak.upgrade() { view_
Some(h) => h, .set_vadjustment()
None => return, .map_err(|err| format!("{}", err))
}; .ok();
if let Some(ref v) = vadj {
home.view.set_adjutments(None, Some(v))
};
}; };
lazy_load_full(episodes, func, callback); lazy_load_full(episodes, func, callback);
home.view.container().show_all(); view.container.show_all();
Ok(home) Ok(view)
}
/// Set scrolled window vertical adjustment.
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(())
} }
} }
@@ -156,19 +172,21 @@ fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct HomeEpisode { struct HomeEpisode {
container: gtk::Box, container: gtk::Box,
image: gtk::Image, image: gtk::Image,
// FIXME: Change it to `EpisodeWidget` instead of a `Box`?
episode: gtk::Box, episode: gtk::Box,
} }
impl Default for HomeEpisode { impl Default for HomeEpisode {
fn default() -> Self { fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/home_episode.ui"); let builder =
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap(); let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap(); let image: gtk::Image = builder.get_object("cover").unwrap();
let ep = EpisodeWidget::default(); let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 0); container.pack_start(&ep.container, true, true, 6);
HomeEpisode { HomeEpisode {
container, container,
@@ -179,11 +197,12 @@ impl Default for HomeEpisode {
} }
impl HomeEpisode { impl HomeEpisode {
fn new(episode: EpisodeWidgetModel, sender: &Sender<Action>) -> HomeEpisode { fn new(episode: &EpisodeWidgetQuery, sender: &Sender<Action>) -> HomeEpisode {
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/home_episode.ui"); let builder =
gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap(); let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap(); let image: gtk::Image = builder.get_object("cover").unwrap();
let pid = episode.show_id(); let pid = episode.podcast_id();
let ep = EpisodeWidget::new(episode, sender); let ep = EpisodeWidget::new(episode, sender);
let view = HomeEpisode { let view = HomeEpisode {
@@ -196,28 +215,15 @@ impl HomeEpisode {
view view
} }
fn init(&self, show_id: i32) { fn init(&self, podcast_id: i32) {
self.set_cover(show_id); self.set_cover(podcast_id)
self.container.pack_start(&self.episode, true, true, 0); .map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
self.container.pack_start(&self.episode, true, true, 6);
} }
fn set_cover(&self, show_id: i32) { fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
// The closure above is a regular `Fn` closure. utils::set_image_from_path(&self.image, podcast_id, 64)
// which means we can't mutate stuff inside it easily,
// so Cell is used.
//
// `Option<T>` along with the `.take()` method ensure
// that the function will only be run once, during the first execution.
let show_id = Cell::new(Some(show_id));
self.image.connect_draw(move |image, _| {
show_id.take().map(|id| {
utils::set_image_from_path(image, id, 64)
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
});
gtk::Inhibit(false)
});
} }
} }
+15
View File
@@ -0,0 +1,15 @@
mod aboutdialog;
pub mod appnotif;
mod empty;
mod episode;
mod home_view;
mod show;
mod shows_view;
pub use self::aboutdialog::about_dialog;
pub use self::empty::EmptyView;
pub use self::episode::EpisodeWidget;
pub use self::home_view::HomeView;
pub use self::show::ShowWidget;
pub use self::show::{mark_all_notif, remove_show_notif};
pub use self::shows_view::ShowsView;
+347
View File
@@ -0,0 +1,347 @@
use glib;
use gtk;
use gtk::prelude::*;
use crossbeam_channel::{SendError, Sender};
use failure::Error;
use html2pango::markup_from_raw;
use open;
use rayon;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::utils::delete_show;
use hammond_data::Podcast;
use app::Action;
use utils::{self, lazy_load};
use widgets::appnotif::{InAppNotification, UndoState};
use widgets::EpisodeWidget;
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 {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
cover: gtk::Image,
description: gtk::Label,
link: gtk::Button,
settings: gtk::MenuButton,
unsub: gtk::Button,
episodes: gtk::ListBox,
podcast_id: Option<i32>,
}
impl Default for ShowWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").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();
let unsub: gtk::Button = builder.get_object("unsub_button").unwrap();
let link: gtk::Button = builder.get_object("link_button").unwrap();
let settings: gtk::MenuButton = builder.get_object("settings_button").unwrap();
ShowWidget {
container,
scrolled_window,
cover,
description,
unsub,
link,
settings,
episodes,
podcast_id: None,
}
}
}
impl ShowWidget {
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> Rc<ShowWidget> {
let mut pdw = ShowWidget::default();
pdw.init(&pd, &sender);
let pdw = Rc::new(pdw);
populate_listbox(&pdw, pd, sender)
.map_err(|err| error!("Failed to populate the listbox: {}", err))
.ok();
pdw
}
pub fn init(&mut self, pd: &Arc<Podcast>, sender: &Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/show_widget.ui");
self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| {
on_unsub_button_clicked(pd.clone(), bttn, &sender);
}));
self.set_description(pd.description());
self.podcast_id = Some(pd.id());
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);
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();
let mark_all: gtk::ModelButton = builder.get_object("mark_all_watched").unwrap();
let episodes = self.episodes.clone();
mark_all.connect_clicked(clone!(pd, sender => move |_| {
on_played_button_clicked(
pd.clone(),
&episodes,
&sender
)
}));
self.settings.set_popover(&show_menu);
}
/// Set the show cover.
fn set_cover(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
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));
}
/// 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.
fn set_vadjustment(&self, pd: &Arc<Podcast>) -> Result<(), Error> {
let guard = SHOW_WIDGET_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some((oldid, ref sendcell)) = *guard {
// Only copy the old scrollbar if both widget's represent the same podcast.
debug!("PID: {}", pd.id());
debug!("OLDID: {}", oldid);
if pd.id() != oldid {
debug!("Early return");
return Ok(());
};
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
pub fn podcast_id(&self) -> Option<i32> {
self.podcast_id
}
}
/// Populate the listbox with the shows episodes.
fn populate_listbox(
show: &Rc<ShowWidget>,
pd: Arc<Podcast>,
sender: Sender<Action>,
) -> Result<(), Error> {
use crossbeam_channel::bounded;
use crossbeam_channel::TryRecvError::*;
let count = dbqueries::get_pd_episodes_count(&pd)?;
let (sender_, receiver) = bounded(1);
rayon::spawn(clone!(pd => move || {
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
// The receiver can be dropped if there's an early return
// like on show without episodes for example.
sender_.send(episodes).ok();
}));
if count == 0 {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
show.episodes.add(&container);
return Ok(());
}
let show_ = show.clone();
gtk::idle_add(move || {
let episodes = match receiver.try_recv() {
Ok(e) => e,
Err(Empty) => return glib::Continue(true),
Err(Disconnected) => return glib::Continue(false),
};
let list = show_.episodes.clone();
let constructor = clone!(sender => move |ep| {
EpisodeWidget::new(&ep, &sender).container.clone()
});
let callback = clone!(pd, show_ => move || {
show_.set_vadjustment(&pd)
.map_err(|err| error!("Failed to set ShowWidget Alignment: {}", err))
.ok();
});
lazy_load(episodes, list.clone(), constructor, callback);
glib::Continue(false)
});
Ok(())
}
fn on_unsub_button_clicked(pd: Arc<Podcast>, unsub_button: &gtk::Button, sender: &Sender<Action>) {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.set_sensitive(false);
let wrap = || -> Result<(), SendError<_>> {
sender.send(Action::RemoveShow(pd))?;
sender.send(Action::HeaderBarNormal)?;
sender.send(Action::ShowShowsAnimated)?;
// Queue a refresh after the switch to avoid blocking the db.
sender.send(Action::RefreshShowsView)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
};
wrap().map_err(|err| error!("Action Sender: {}", err)).ok();
unsub_button.set_sensitive(true);
}
fn on_played_button_clicked(pd: Arc<Podcast>, episodes: &gtk::ListBox, sender: &Sender<Action>) {
if dim_titles(episodes).is_none() {
error!("Something went horribly wrong when dimming the titles.");
warn!("RUN WHILE YOU STILL CAN!");
}
sender
.send(Action::MarkAllPlayerNotification(pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
fn mark_all_watched(pd: &Podcast, sender: &Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?;
// Not all widgets migth have been loaded when the mark_all is hit
// So we will need to refresh again after it's done.
sender.send(Action::RefreshWidgetIfSame(pd.id()))?;
sender.send(Action::RefreshEpisodesView).map_err(From::from)
}
pub fn mark_all_notif(pd: Arc<Podcast>, sender: &Sender<Action>) -> InAppNotification {
let id = pd.id();
let callback = clone!(sender => move || {
mark_all_watched(&pd, &sender)
.map_err(|err| error!("Notif Callback Error: {}", err))
.ok();
glib::Continue(false)
});
let undo_callback = clone!(sender => move || {
sender.send(Action::RefreshWidgetIfSame(id))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
});
let text = "Marked all episodes as listened";
InAppNotification::new(text, callback, undo_callback, UndoState::Shown)
}
pub fn remove_show_notif(pd: Arc<Podcast>, sender: Sender<Action>) -> InAppNotification {
let text = format!("Unsubscribed from {}", pd.title());
utils::ignore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not insert {} to the ignore list.", pd.title()))
.ok();
let callback = clone!(pd, sender => move || {
utils::uningore_show(pd.id())
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not remove {} from the ignore list.", pd.title()))
.ok();
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd, sender => move || {
delete_show(&pd)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Failed to delete {}", pd.title()))
.ok();
sender.send(Action::RefreshEpisodesView).ok();
}));
glib::Continue(false)
});
let undo_wrap = move || -> Result<(), Error> {
utils::uningore_show(pd.id())?;
sender.send(Action::RefreshShowsView)?;
sender.send(Action::RefreshEpisodesView)?;
Ok(())
};
let undo_callback = move || {
undo_wrap().map_err(|err| error!("{}", err)).ok();
};
InAppNotification::new(&text, callback, undo_callback, UndoState::Shown)
}
// Ideally if we had a custom widget this would have been as simple as:
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
// But now I can't think of a better way to do it than hardcoding the title
// position relative to the EpisodeWidget container gtk::Box.
fn dim_titles(episodes: &gtk::ListBox) -> Option<()> {
let children = episodes.get_children();
for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let foo = container
.get_children()
.remove(0)
.downcast::<gtk::Box>()
.ok()?;
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
let title = bar.get_children().remove(0).downcast::<gtk::Label>().ok()?;
title.get_style_context().map(|c| c.add_class("dim-label"));
}
Some(())
}
+168
View File
@@ -0,0 +1,168 @@
use gtk;
use gtk::prelude::*;
use crossbeam_channel::Sender;
use failure::Error;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use app::Action;
use utils::{self, get_ignored_shows, lazy_load, set_image_from_path};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
lazy_static! {
static ref SHOWS_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> = Mutex::new(None);
}
#[derive(Debug, Clone)]
pub struct ShowsView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsView {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsView {
pub fn new(sender: Sender<Action>) -> Result<Rc<Self>, Error> {
let pop = Rc::new(ShowsView::default());
pop.init(sender);
// Populate the flowbox with the Podcasts.
populate_flowbox(&pop)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) {
self.flowbox.connect_child_activated(move |_, child| {
on_child_activate(child, &sender)
.map_err(|err| error!("Error along flowbox child activation: {}", err))
.ok();
});
}
/// Set scrolled window vertical adjustment.
#[allow(unused)]
fn set_vadjustment(&self) -> Result<(), Error> {
let guard = SHOWS_VIEW_VALIGNMENT
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
}
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = SHOWS_VIEW_VALIGNMENT.lock() {
let adj = self
.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn populate_flowbox(shows: &Rc<ShowsView>) -> Result<(), Error> {
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
let constructor = move |parent| ShowsChild::new(&parent).child;
let callback = clone!(shows => move || {
shows.set_vadjustment()
.map_err(|err| error!("Failed to set ShowsView Alignment: {}", err))
.ok();
});
let flowbox = shows.flowbox.clone();
lazy_load(podcasts, flowbox, constructor, callback);
Ok(())
}
fn on_child_activate(child: &gtk::FlowBoxChild, sender: &Sender<Action>) -> Result<(), Error> {
use gtk::WidgetExt;
// This is such an ugly hack...
let id = WidgetExt::get_name(child)
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.parse::<i32>()?;
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
sender.send(Action::HeaderBarShowTile(pd.title().into()))?;
sender.send(Action::ReplaceWidget(pd))?;
sender.send(Action::ShowWidgetAnimated)?;
Ok(())
}
#[derive(Debug)]
struct ShowsChild {
container: gtk::Box,
cover: gtk::Image,
child: gtk::FlowBoxChild,
}
impl Default for ShowsChild {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/Hammond/gtk/shows_child.ui");
let container: gtk::Box = builder.get_object("fb_child").unwrap();
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
let child = gtk::FlowBoxChild::new();
child.add(&container);
ShowsChild {
container,
cover,
child,
}
}
}
impl ShowsChild {
pub fn new(pd: &Podcast) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: &Podcast) {
self.container.set_tooltip_text(pd.title());
WidgetExt::set_name(&self.child, &pd.id().to_string());
self.set_cover(pd.id())
.map_err(|err| error!("Failed to set a cover: {}", err))
.ok();
}
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
set_image_from_path(&self.cover, podcast_id, 256)
}
}
+9 -6
View File
@@ -5,22 +5,25 @@
xmlns:gnome="http://api.gnome.org/doap-extensions#" xmlns:gnome="http://api.gnome.org/doap-extensions#"
xmlns="http://usefulinc.com/ns/doap#"> xmlns="http://usefulinc.com/ns/doap#">
<name xml:lang="en">Podcasts</name> <name xml:lang="en">Hammond</name>
<shortdesc xml:lang="en"> <shortdesc xml:lang="en">
Podcast app for GNOME Modern Podcast Client for the GNOME desktop
</shortdesc> </shortdesc>
<description xml:lang="en"> <description xml:lang="en">
Listen to your favorite podcasts, right from your desktop. <p>
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
desktop written in Rust.
</p>
</description> </description>
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Podcasts" /> --> <!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
<bug-database rdf:resource="https://gitlab.gnome.org/World/podcasts/issues" /> <bug-database rdf:resource="https://gitlab.gnome.org/World/hammond/issues" />
<category rdf:resource="http://api.gnome.org/doap-extensions#apps" /> <category rdf:resource="http://api.gnome.org/doap-extensions#apps" />
<programming-language>Rust</programming-language> <programming-language>Rust</programming-language>
<maintainer> <maintainer>
<foaf:Person> <foaf:Person>
<foaf:name>Jordan Petridis</foaf:name> <foaf:name>Jordan petridis</foaf:name>
<foaf:mbox rdf:resource="mailto:jpetridis@gnome.org" /> <foaf:mbox rdf:resource="mailto:jpetridis@gnome.org" />
<gnome:userid>jpetridis</gnome:userid> <gnome:userid>jpetridis</gnome:userid>
</foaf:Person> </foaf:Person>
+17 -46
View File
@@ -2,46 +2,23 @@
# https://gitlab.gnome.org/danigm/fractal/blob/6e2911f9d2353c99a18a6c19fab7f903c4bbb431/meson.build # https://gitlab.gnome.org/danigm/fractal/blob/6e2911f9d2353c99a18a6c19fab7f903c4bbb431/meson.build
project( project(
'gnome-podcasts', 'rust', 'hammond', 'rust',
version: '0.4.5', version: '0.3.4',
license: 'GPLv3', license: 'GPLv3',
) )
if get_option('profile') == 'development' hammond_version = meson.project_version()
profile = 'Devel' version_array = hammond_version.split('.')
vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip() hammond_major_version = version_array[0].to_int()
if vcs_tag == '' hammond_minor_version = version_array[1].to_int()
version_suffix = '-devel' hammond_version_micro = version_array[2].to_int()
else
version_suffix = '-@0@'.format (vcs_tag)
endif
else
profile = ''
version_suffix = ''
endif
application_id = 'org.gnome.Podcasts@0@'.format(profile) hammond_prefix = get_option('prefix')
i18n = import('i18n') hammond_bindir = join_paths(hammond_prefix, get_option('bindir'))
subdir('podcasts-gtk/po')
podir = join_paths (meson.source_root (), 'podcasts-gtk', 'po')
podcasts_version = meson.project_version()
version_array = podcasts_version.split('.')
podcasts_major_version = version_array[0].to_int()
podcasts_minor_version = version_array[1].to_int()
podcasts_version_micro = version_array[2].to_int()
podcasts_prefix = get_option('prefix')
podcasts_bindir = join_paths(podcasts_prefix, get_option('bindir'))
podcasts_localedir = join_paths(podcasts_prefix, get_option('localedir'))
podcasts_conf = configuration_data()
podcasts_conf.set('appid', application_id)
podcasts_conf.set('bindir', podcasts_bindir)
datadir = get_option('datadir') datadir = get_option('datadir')
subdir('podcasts-gtk/resources') icondir = join_paths(datadir, 'icons')
subdir('hammond-gtk/resources')
cargo = find_program('cargo', required: false) cargo = find_program('cargo', required: false)
gresource = find_program('glib-compile-resources', required: false) gresource = find_program('glib-compile-resources', required: false)
@@ -49,19 +26,13 @@ cargo_vendor = find_program('cargo-vendor', required: false)
cargo_script = find_program('scripts/cargo.sh') cargo_script = find_program('scripts/cargo.sh')
cargo_release = custom_target('cargo-build', cargo_release = custom_target('cargo-build',
build_always_stale: true, build_by_default: true,
output: ['gnome-podcasts'], build_always: true,
output: ['hammond'],
install: true, install: true,
install_dir: podcasts_bindir, install_dir: hammond_bindir,
command: [cargo_script, command: [cargo_script, '@CURRENT_SOURCE_DIR@', '@OUTPUT@'])
'@CURRENT_SOURCE_DIR@',
'@OUTPUT@',
podcasts_localedir,
application_id,
podcasts_version + version_suffix,
profile
])
run_target('release', command: ['scripts/release.sh', run_target('release', command: ['scripts/release.sh',
meson.project_name() + '-' + podcasts_version meson.project_name() + '-' + hammond_version
]) ])
-9
View File
@@ -1,9 +0,0 @@
option (
'profile',
type: 'combo',
choices: [
'default',
'development'
],
value: 'default'
)
@@ -1,12 +1,12 @@
{ {
"app-id" : "org.gnome.PodcastsDevel", "app-id" : "org.gnome.Hammond",
"runtime" : "org.gnome.Platform", "runtime" : "org.gnome.Platform",
"runtime-version" : "master", "runtime-version" : "3.28",
"sdk" : "org.gnome.Sdk", "sdk" : "org.gnome.Sdk",
"sdk-extensions" : [ "sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable" "org.freedesktop.Sdk.Extension.rust-stable"
], ],
"command" : "gnome-podcasts", "command" : "hammond",
"tags" : [ "tags" : [
"nightly" "nightly"
], ],
@@ -20,8 +20,7 @@
"--share=ipc", "--share=ipc",
"--socket=x11", "--socket=x11",
"--socket=wayland", "--socket=wayland",
"--socket=pulseaudio", "--talk-name=org.freedesktop.Desktop"
"--device=dri"
], ],
"build-options" : { "build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin", "append-path" : "/usr/lib/sdk/rust-stable/bin",
@@ -29,29 +28,19 @@
"--share=network" "--share=network"
], ],
"env" : { "env" : {
"CARGO_HOME" : "/run/build/Podcasts/cargo", "CARGO_HOME" : "/run/build/Hammond/cargo",
"RUST_BACKTRACE" : "1" "RUST_BACKTRACE" : "1",
"DEBUG" : "true"
} }
}, },
"modules" : [ "modules" : [
{ {
"name" : "libhandy", "name" : "hammond",
"buildsystem" : "meson", "buildsystem" : "meson",
"sources" : [ "sources" : [
{ {
"type" : "git", "type" : "git",
"url" : "https://source.puri.sm/Librem5/libhandy.git" "url" : "https://gitlab.gnome.org/World/hammond.git",
}
]
},
{
"name" : "gnome-podcasts",
"buildsystem" : "meson",
"config-opts" : [ "-Dprofile=development" ],
"sources" : [
{
"type" : "git",
"url" : "https://gitlab.gnome.org/World/podcasts.git",
"branch" : "master" "branch" : "master"
} }
] ]
-6
View File
@@ -1,6 +0,0 @@
# 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"
@@ -1,53 +0,0 @@
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;
@@ -1,66 +0,0 @@
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;
@@ -1,40 +0,0 @@
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;

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