9 Commits
Author SHA1 Message Date
Jordan Petridis 85983ec490 Load **every** episode for benchmark purposes. 2018-04-07 00:26:02 +03:00
Jordan Petridis 7abf6bcade Lazy_load: remove unnecessary clone of an Rc pointer. 2018-04-06 23:12:32 +03:00
Jordan Petridis 854581f0bf Lazy_load: Use IntoIterator for T, instead of Iterator. 2018-04-06 22:56:44 +03:00
Jordan Petridis c79a92f3b2 Lazy_load: accept an iterator instead a Vec<_> over T. 2018-04-06 22:37:40 +03:00
Jordan Petridis 4d6c3a67b1 Lazy_load: Avoid manually indexing.
make the data: Vec<T> mutable, then reverse the vector
so it can be used as a stack, and then use the ::pop()
method to retrieve the item.

This also avoid the constrain for Clone on T.
2018-04-06 22:37:36 +03:00
Jordan Petridis 83abb5a825 Move the lazy_load logic to a Generic function. 2018-04-06 22:37:33 +03:00
Jordan Petridis d618771125 EpisodesListBox: Do not block while fetching episode backlog. 2018-04-06 17:06:14 +00:00
Jordan Petridis 45c9fd308d EpisodesListBox: Add each widget lazyly. 2018-04-06 17:06:14 +00:00
Jordan Petridis 0c00ee1320 ShowWidget: Initial Lazier evaluation of the widgets. 2018-04-06 17:06:13 +00:00
62 changed files with 2272 additions and 2842 deletions
+19 -55
View File
@@ -1,27 +1,13 @@
stages: stages:
- test - test
- lint - lint
- review
variables:
BUNDLE: "hammond-dev.flatpak"
.cargo_cache_template: &cargo_cache
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
.cargo_test_template: &cargo_test .cargo_test_template: &cargo_test
stage: test stage: test
variables: # variables:
RUSTFLAGS: "--cfg rayon_unstable" # RUSTFLAGS: "-C link-dead-code"
RUST_BACKTRACE: "FULL" # RUST_BACKTRACE: "FULL"
before_script: before_script:
- apt-get update -yqq - apt-get update -yqq
@@ -39,7 +25,15 @@ variables:
- cargo build - cargo build
- cargo test -- --test-threads=1 - cargo test -- --test-threads=1
- cargo test -- --test-threads=1 --ignored - cargo test -- --test-threads=1 --ignored
<<: *cargo_cache
cache:
# JOB_NAME - Each job will have it's own cache
# COMMIT_REF_SLUG = Lowercase name of the branch
# ^ Keep diffrerent caches for each branch
key: "$CI_JOB_NAME"
paths:
- target/
- .cargo_cache/
rust:stable: rust:stable:
# https://hub.docker.com/_/rust/ # https://hub.docker.com/_/rust/
@@ -59,7 +53,7 @@ flatpak:
stage: test stage: test
script: script:
- flatpak-builder --stop-at=hammond app org.gnome.Hammond.json - flatpak-builder --stop-at=hammond app org.gnome.Hammond.json
# https://gitlab.gnome.org/World/hammond/issues/55 # https://gitlab.gnome.org/alatiera/Hammond/issues/55
# Force regeneration of gresources regardless of artifacts chage # Force regeneration of gresources regardless of artifacts chage
- flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml - flatpak-builder --run app org.gnome.Hammond.json glib-compile-resources --sourcedir=hammond-gtk/resources/ hammond-gtk/resources/resources.xml
@@ -70,15 +64,15 @@ flatpak:
- flatpak build-export repo app - flatpak build-export repo app
# Create a flatpak bundle # Create a flatpak bundle
- flatpak build-bundle repo ${BUNDLE} org.gnome.Hammond - flatpak build-bundle repo hammond-dev.flatpak org.gnome.Hammond
# Run the tests # Run the tests
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 # - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1
# - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored # - flatpak-builder --run app org.gnome.Hammond.json cargo test -- --test-threads=1 --ignored
artifacts: artifacts:
paths: paths:
- $BUNDLE - hammond-dev.flatpak
expire_in: 30 days expire_in: 2 days
cache: cache:
# JOB_NAME - Each job will have it's own cache # JOB_NAME - Each job will have it's own cache
@@ -89,34 +83,6 @@ flatpak:
- .flatpak-builder/cache/ - .flatpak-builder/cache/
- target/ - target/
review:
stage: review
dependencies:
- flatpak
script:
- echo "Generating flatpak deployment"
artifacts:
paths:
- $BUNDLE
expire_in: 30 days
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://gitlab.gnome.org/$CI_PROJECT_PATH/-/jobs/$CI_JOB_ID/artifacts/raw/${BUNDLE}
on_stop: stop_review
except:
- master@World/hammond
stop_review:
stage: review
script:
- echo "Stopping flatpak deployment"
when: manual
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
except:
- master@World/hammond
# Configure and run rustfmt on nightly # Configure and run rustfmt on nightly
# Exits and builds fails if on bad format # Exits and builds fails if on bad format
rustfmt: rustfmt:
@@ -130,14 +96,12 @@ rustfmt:
# Configure and run clippy on nightly # Configure and run clippy on nightly
# Only fails on errors atm. # Only fails on errors atm.
clippy: clippy:
image: "registry.gitlab.gnome.org/alatiera/hammond-container-images/clippy:nightly" image: "rustlang/rust:nightly"
stage: lint stage: lint
variables:
RUSTFLAGS: "--cfg rayon_unstable"
script: script:
- rustc --version && cargo --version - rustc --version && cargo --version
- cargo clippy --version - cargo install clippy --force
# Force regeneration of gresources regardless of artifacts chage # Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../ - cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo clippy --all - cargo clippy --all
<<: *cargo_cache when: manual
-42
View File
@@ -1,42 +0,0 @@
Current problems
<!--
What are the problems that the current project has?
For example:
* User cannot use the keyboard to perform most common actions
or
* User cannot see documents from cloud services
-->
# Goals & use cases
<!--
What are the use cases that this proposal will cover? What are the end goals?
For example:
* User needs to share a file with their friends.
or
* It should be easy to edit a picture within the app.
-->
# Requirements
<!--
What does the solution needs to ensure for being succesful?
For example:
* Work on small form factors and touch
or
* Use the Meson build system and integrate with it
-->
# Relevant art
<!--
Is there any product that has implemented something similar? Put links to other
projects, pictures, links to other code, etc.
-->
# Proposal & plan
<!-- What's the solution and how should be achieved? It can be split in smaller
tasks of minimum change, so they can be delivered across several releases. -->
/label ~"Epic"
+38 -72
View File
@@ -5,87 +5,53 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added:
### Changed:
### Fixed:
### Removed:
## [0.3.3] - 2018-05-19 * Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
### Added: [#7](https://gitlab.gnome.org/alatiera/Hammond/issues/7)
- Initial functionality for importing shows from an OPML file was implemented. * Bold, italics links and some other `html` tags can now be rendered in the Show Description.
- ShowsView now rembmers the vertical alignment of the scrollbar between refreshes. 4d2b64e79d8518454b3677612664cd32044cf837 [#25](https://gitlab.gnome.org/alatiera/Hammond/issues/25)
### Changed:
- Minimum `rustc` version requirment was bumped to `1.26`
- Some animations should be smoother now. 7d598bb1d08b05fd5ab532657acdad967c0afbc3
- InAppNotification now can be used to propagate some erros to the user. 7035fe05c4741b3e7ccce6827f72766226d5fc0a and 118dac5a1ab79c0b4ebe78e88256a4a38b138c04
### Fixed:
- Fixed a of by one bug in the `ShowsView` where the last show was never shown. bd12b09cbc8132fd39a266fd091e24bc6c3c040f
## [0.3.2] - 2018-05-07
### Added:
- Vies now have a new fancy scrolling animation when they are refereshed.
### Changed:
- Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
[#7](https://gitlab.gnome.org/World/hammond/issues/7)
- Bold, italics links and some other `html` tags can now be rendered in the Show Description.
[#25](https://gitlab.gnome.org/World/hammond/issues/25)
- `Rayon` Threadpools are now used instead of unlimited one-off threads.
- `EpisdeWidget`s are now loaded asynchronously accross views.
- `EpisodeWidget`s no longer trigger a `View` refresh for trivial stuff 03bd95184808ccab3e0ea0e3713a52ee6b7c9ab4
- `ShowWidget` layout was changed 9a5cc1595d982f3232ee7595b83b6512ac8f6c88
- `ShowWidget` Description is inside a scrolled window now
### Fixed:
- `EpisodeWidget` Height now is consistent accros views [#57](https://gitlab.gnome.org/World/hammond/issues/57)
- Implemented a tail-recursion loop to follow-up when a feed redirects to another url. c6a24e839a8ba77d09673f299cfc1e64ba7078f3
### Removed:
- Removed the custom configuration file and replaced instructions to just use meson. 1f1d4af8ba7db8f56435d13a1c191ecff3d4a85b
## [0.3.1] - 2018-03-28 ## [0.3.1] - 2018-03-28
### Added:
- Ability to mark all episodes of a Show as watched.
[#47](https://gitlab.gnome.org/World/hammond/issues/47)
- Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
[#49](https://gitlab.gnome.org/World/hammond/issues/49)
- Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/World/hammond/issues/50)
- Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/World/hammond/merge_requests/22) [!23](https://gitlab.gnome.org/World/hammond/merge_requests/23)
- Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/World/hammond/issues/44)
### Changed: * Ability to mark all episodes of a Show as watched.
- EpisdeWidget has been reimplemented as a compile time state machine. [#47](https://gitlab.gnome.org/alatiera/Hammond/issues/47)
[!18](https://gitlab.gnome.org/World/hammond/merge_requests/18) * Now you are able to subscribe to itunes™ podcasts by using the itunes link of the show.
- Content Views no longer scroll horizontally when shrunk bellow their minimum size. [#49](https://gitlab.gnome.org/alatiera/Hammond/issues/49)
[#35](https://gitlab.gnome.org/World/hammond/issues/35) * EpisdeWidget has been reimplemented as a compile time state machine.
- Some requests now use the Tor Browser's user agent. (Rowan Lewis) [!18](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/18)
[#53](https://gitlab.gnome.org/World/hammond/issues/53) * Content Views no longer scroll horizontally when shrunk bellow their minimum size.
[#35](https://gitlab.gnome.org/alatiera/Hammond/issues/35)
### 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/alatiera/Hammond/issues/52)
[#52](https://gitlab.gnome.org/World/hammond/issues/52) * Some requests now use the Tor Browser's user agent. (Rowan Lewis)
[#53](https://gitlab.gnome.org/alatiera/Hammond/issues/53)
* Hammond now remembers the window size and position. (Rowan Lewis)
[#50](https://gitlab.gnome.org/alatiera/Hammond/issues/50)
* Implemnted the initial work for integrating with GSettings and storing preferences. (Rowan Lewis)
[!22](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/22) [!23](https://gitlab.gnome.org/alatiera/Hammond/merge_requests/23)
* Shows without episodes now display an empty message similar to EmptyView.
[#44](https://gitlab.gnome.org/alatiera/Hammond/issues/44)
## [0.3.0] - 2018-02-11 ## [0.3.0] - 2018-02-11
- Tobias Bernard Redesigned the whole Gtk+ client.
- Complete re-write of hammond-data and hammond-gtk modules. * Tobias Bernard Redesigned the whole Gtk+ client.
- Error handling for all crates was migrated from error-chain to Failure. * Complete re-write of hammond-data and hammond-gtk modules.
- Hammond-data now uses futures to parse feeds. * Error handling for all crates was migrated from error-chain to Failure.
- Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets. * Hammond-data now uses futures to parse feeds.
* Custom gtk-widgets are now composed structs as opposed to functions returning Gtk widgets.
## [0.2.0] - 2017-11-28 ## [0.2.0] - 2017-11-28
- Database Schema Breaking Changes.
- Added url sanitization. #4. * Database Schema Breaking Changes.
- Reworked and refactored of the hammond-data API. * Added url sanitization. #4.
- Added some more unit tests * Reworked and refactored of the hammond-data API.
- Documented hammond-data public API. * Added some more unit tests
* Documented hammond-data public API.
## [0.1.1] - 2017-11-13 ## [0.1.1] - 2017-11-13
- Added appdata.xml file
* Added appdata.xml file
## [0.1.0] - 2017-11-13 ## [0.1.0] - 2017-11-13
- Initial Release
Initial Release
+2 -2
View File
@@ -12,7 +12,7 @@ Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOf
## Source repository ## Source repository
Hammond'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/hammond) the web interface [here](https://gitlab.gnome.org/alatiera/hammond)
Development happens in the master branch. Development happens in the master branch.
@@ -51,7 +51,7 @@ In order to run the test suite use the following: `cargo test -- --test-threads=
# Issues, issues and more issues! # Issues, issues and more issues!
There are many ways you can contribute to Hammond, and all of them involve creating issues There are many ways you can contribute to Hammond, and all of them involve creating issues
in [Hammond issue tracker](https://gitlab.gnome.org/World/hammond/issues). This is the entry point for your contribution. in [Hammond issue tracker](https://gitlab.gnome.org/alatiera/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:
Generated
+302 -400
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,3 +7,4 @@ members = [
[profile.release] [profile.release]
debug = false debug = false
+23 -22
View File
@@ -1,6 +1,13 @@
# Hammond # Hammond
### A Podcast Client for GNOME written in Rust. ## A Podcast Client for the GNOME Desktop written in Rust.
[![pipeline status](https://gitlab.gnome.org/alatiera/Hammond/badges/master/pipeline.svg)](https://gitlab.gnome.org/alatiera/Hammond/commits/master)
[![Dependency Status](https://dependencyci.com/github/alatiera/Hammond/badge)](https://dependencyci.com/github/alatiera/Hammond)
### Features
* TBA
![episdes_view](./screenshots/episodes_view.png) ![episdes_view](./screenshots/episodes_view.png)
![shows_view](./screenshots/shows_view.png) ![shows_view](./screenshots/shows_view.png)
@@ -15,13 +22,12 @@ Get Builder [here](https://wiki.gnome.org/Apps/Builder/Downloads)
## Broken Feeds ## Broken Feeds
Found a feed that does not work in Hammond? Found a feed that does not work in Hammond?
Please [open an issue](https://gitlab.gnome.org/World/hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it! Please [open an issue](https://gitlab.gnome.org/alatiera/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 Hammond, 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 want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
[irc.gnome.org.][irc] or [matrix][matrix].
Note: Note:
@@ -47,28 +53,25 @@ 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 --user install flathub org.freedesktop.Sdk.Extension.rust-stable flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable
flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean flatpak-builder --user --repo=repo hammond org.gnome.Hammond.json --force-clean
``` flatpak build-bundle repo hammond org.gnome.Hammond
To install the resulting flatpak you can do:
```bash
flatpak build-bundle repo hammond.flatpak org.gnome.Hammond
flatpak install --user --bundle hammond.flatpak
``` ```
### Building from soure ### Building from soure
```sh ```sh
git clone https://gitlab.gnome.org/World/hammond.git git clone https://gitlab.gnome.org/alatiera/hammond.git
cd hammond/ cd hammond/
meson --prefix=/usr build ./configure --prefix=/usr/local
ninja -C build make && sudo make install
sudo ninja -C build install
``` ```
**Additional:**
You can run `sudo make uninstall` for removal
#### Dependencies #### Dependencies
* Rust stable 1.26 or later along with cargo. * Rust stable 1.22 or later along with cargo.
* Gtk+ 3.22 or later * Gtk+ 3.22 or later
* Meson * Meson
* A network connection * A network connection
@@ -98,11 +101,11 @@ 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](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=). You can start by taking a look at [Issues](https://gitlab.gnome.org/alatiera/Hammond/issues) or by opening a [New issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new?issue%5Bassignee_id%5D=&issue%5Bmilestone_id%5D=).
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/hammond/blob/master/CONTRIBUTING.md [contribution-guidelines]: https://gitlab.gnome.org/alatiera/Hammond/blob/master/CONTRIBUTING.md
## Overview ## Overview
@@ -122,7 +125,7 @@ $ tree -d
│   ├── resources # GResources folder │   ├── resources # GResources folder
│   │   └── gtk # Contains the glade.ui files. │   │   └── gtk # Contains the glade.ui files.
│   └── src │   └── src
│   ├── stacks # Contains the gtk Stacks that hold all the different views. │   ├── views # Contains the Empty, Episodes and Shows view.
│   └── widgets # Contains custom widgets such as Show and Episode. │   └── widgets # Contains custom widgets such as Show and Episode.
``` ```
@@ -140,6 +143,4 @@ We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/App
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project. And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
[vendor]: https://github.com/alexcrichton/cargo-vendor [vendor]: https://github.com/alexcrichton/cargo-vendor
[irc]: irc://irc.gnome.org/#hammond
[matrix]: https://matrix.to/#/#hammond:matrix.org
Vendored Executable
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# Adapted from:
# https://gitlab.gnome.org/danigm/libgepub/blob/27f0d374e0c8f6fa972dbd111d4ce0c0f3096914/configure_meson
# configure script adapter for Meson
# Based on build-api: https://github.com/cgwalters/build-api
# Copyright 2010, 2011, 2013 Colin Walters <walters@verbum.org>
# Copyright 2016, 2017 Emmanuele Bassi
# Copyright 2017 Iñigo Martínez <inigomartinez@gmail.com>
# Licensed under the new-BSD license (http://www.opensource.org/licenses/bsd-license.php)
# Build API variables:
# Little helper function for reading args from the commandline.
# it automatically handles -a b and -a=b variants, and returns 1 if
# we need to shift $3.
read_arg() {
# $1 = arg name
# $2 = arg value
# $3 = arg parameter
local rematch='^[^=]*=(.*)$'
if [[ $2 =~ $rematch ]]; then
read "$1" <<< "${BASH_REMATCH[1]}"
else
read "$1" <<< "$3"
# There is no way to shift our callers args, so
# return 1 to indicate they should do it instead.
return 1
fi
}
sanitycheck() {
# $1 = arg name
# $1 = arg command
# $2 = arg alternates
local cmd=$( which $2 2>/dev/null )
if [ -x "$cmd" ]; then
read "$1" <<< "$cmd"
return 0
fi
test -z $3 || {
for alt in $3; do
cmd=$( which $alt 2>/dev/null )
if [ -x "$cmd" ]; then
read "$1" <<< "$cmd"
return 0
fi
done
}
echo -e "\e[1;31mERROR\e[0m: Command '$2' not found"
exit 1
}
checkoption() {
# $1 = arg
option="${1#*--}"
action="${option%%-*}"
name="${option#*-}"
if [ ${default_options[$name]+_} ]; then
case "$action" in
enable) meson_options[$name]=true;;
disable) meson_options[$name]=false;;
*) echo -e "\e[1;33mINFO\e[0m: Ignoring unknown action '$action'";;
esac
else
echo -e "\e[1;33mINFO\e[0m: Ignoring unknown option '$option'"
fi
}
echooption() {
# $1 = option
if [ ${meson_options[$1]+_} ]; then
echo ${meson_options[$1]}
elif [ ${default_options[$1]+_} ]; then
echo ${default_options[$1]}
fi
}
sanitycheck MESON 'meson'
sanitycheck MESONTEST 'mesontest'
sanitycheck NINJA 'ninja' 'ninja-build'
declare -A meson_options
while (($# > 0)); do
case "${1%%=*}" in
--prefix) read_arg prefix "$@" || shift;;
--bindir) read_arg bindir "$@" || shift;;
--sbindir) read_arg sbindir "$@" || shift;;
--libexecdir) read_arg libexecdir "$@" || shift;;
--datarootdir) read_arg datarootdir "$@" || shift;;
--datadir) read_arg datadir "$@" || shift;;
--sysconfdir) read_arg sysconfdir "$@" || shift;;
--libdir) read_arg libdir "$@" || shift;;
--mandir) read_arg mandir "$@" || shift;;
--includedir) read_arg includedir "$@" || shift;;
*) checkoption $1;;
esac
shift
done
# Defaults
test -z ${prefix} && prefix="/usr/local"
test -z ${bindir} && bindir=${prefix}/bin
test -z ${sbindir} && sbindir=${prefix}/sbin
test -z ${libexecdir} && libexecdir=${prefix}/bin
test -z ${datarootdir} && datarootdir=${prefix}/share
test -z ${datadir} && datadir=${datarootdir}
test -z ${sysconfdir} && sysconfdir=${prefix}/etc
test -z ${libdir} && libdir=${prefix}/lib
test -z ${mandir} && mandir=${prefix}/share/man
test -z ${includedir} && includedir=${prefix}/include
# The source directory is the location of this file
srcdir=$(dirname $0)
# The build directory is the current location
builddir=`pwd`
# If we're calling this file from the source directory then
# we automatically create a build directory and ensure that
# both Meson and Ninja invocations are relative to that
# location
if [[ -f "${builddir}/meson.build" ]]; then
mkdir -p _build
builddir="${builddir}/_build"
NINJA_OPT="-C ${builddir}"
fi
# Wrapper Makefile for Ninja
cat > Makefile <<END
# Generated by configure; do not edit
all: rebuild
${NINJA} ${NINJA_OPT}
rebuild:
rm -f ${builddir}/hammond
install:
DESTDIR="\$(DESTDIR)" ${NINJA} ${NINJA_OPT} install
uninstall:
${NINJA} ${NINJA_OPT} uninstall
release:
${NINJA} ${NINJA_OPT} release
check:
${MESONTEST} ${NINJA_OPT}
END
echo "
hammond
=======
meson: ${MESON}
ninja: ${NINJA}
prefix: ${prefix}
Now type 'make' to build
"
cmd_options=""
for key in "${!meson_options[@]}"; do
cmd_options="$cmd_options -Denable-$key=${meson_options[$key]}"
done
exec ${MESON} \
--prefix=${prefix} \
--libdir=${libdir} \
--libexecdir=${libexecdir} \
--datadir=${datadir} \
--sysconfdir=${sysconfdir} \
--bindir=${bindir} \
--includedir=${includedir} \
--mandir=${mandir} \
${cmd_options} \
${builddir} \
${srcdir}
+8 -10
View File
@@ -6,20 +6,19 @@ workspace = "../"
[dependencies] [dependencies]
ammonia = "1.1.0" ammonia = "1.1.0"
chrono = "0.4.2" chrono = "0.4.1"
derive_builder = "0.5.1" derive_builder = "0.5.1"
itertools = "0.7.8"
lazy_static = "1.0.0" lazy_static = "1.0.0"
log = "0.4.1" log = "0.4.1"
rayon = "1.0.1" rayon = "1.0.1"
rayon-futures = "0.1.0"
rfc822_sanitizer = "0.3.3" rfc822_sanitizer = "0.3.3"
rss = "1.5.0" rss = "1.4.0"
url = "1.7.0" url = "1.7.0"
xdg = "2.1.0" xdg = "2.1.0"
xml-rs = "0.8.0"
futures = "0.1.21" futures = "0.1.21"
hyper = "0.11.27" hyper = "0.11.24"
tokio-core = "0.1.17" tokio-core = "0.1.16"
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"
@@ -28,18 +27,17 @@ failure_derive = "0.1.1"
[dependencies.diesel] [dependencies.diesel]
features = ["sqlite", "r2d2"] features = ["sqlite", "r2d2"]
version = "1.2.2" version = "1.1.1"
[dependencies.diesel_migrations] [dependencies.diesel_migrations]
features = ["sqlite"] features = ["sqlite"]
version = "1.2.0" version = "1.1.0"
[dev-dependencies] [dev-dependencies]
rand = "0.4.2" rand = "0.4.2"
tempdir = "0.3.7" tempdir = "0.3.7"
criterion = "0.2.3" criterion = "0.2.2"
pretty_assertions = "0.5.1" pretty_assertions = "0.5.1"
maplit = "1.0.1"
[[bench]] [[bench]]
name = "bench" name = "bench"
+24 -5
View File
@@ -1,5 +1,3 @@
#![allow(unused)]
#[macro_use] #[macro_use]
extern crate criterion; extern crate criterion;
use criterion::Criterion; use criterion::Criterion;
@@ -18,10 +16,10 @@ extern crate rss;
// use futures::future::*; // use futures::future::*;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
use hammond_data::FeedBuilder; use hammond_data::FeedBuilder;
use hammond_data::Source; use hammond_data::Source;
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
// use hammond_data::errors::*; // use hammond_data::errors::*;
use std::io::BufReader; use std::io::BufReader;
@@ -58,6 +56,22 @@ static FEEDS: &[(&[u8], &str)] = &[
(STARS, STARS_URL), (STARS, STARS_URL),
]; ];
// This is broken and I don't know why.
fn bench_pipeline(c: &mut Criterion) {
truncate_db().unwrap();
FEEDS.iter().for_each(|&(_, url)| {
Source::from_url(url).unwrap();
});
c.bench_function("pipline", move |b| {
b.iter(|| {
let sources = hammond_data::dbqueries::get_sources().unwrap();
pipeline::run(sources, true).unwrap();
})
});
truncate_db().unwrap();
}
fn bench_index_large_feed(c: &mut Criterion) { fn bench_index_large_feed(c: &mut Criterion) {
truncate_db().unwrap(); truncate_db().unwrap();
let url = "https://www.greaterthancode.com/feed/podcast"; let url = "https://www.greaterthancode.com/feed/podcast";
@@ -100,5 +114,10 @@ fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap(); truncate_db().unwrap();
} }
criterion_group!(benches, bench_index_large_feed, bench_index_small_feed); criterion_group!(
benches,
bench_pipeline,
bench_index_large_feed,
bench_index_small_feed
);
criterion_main!(benches); criterion_main!(benches);
+22 -61
View File
@@ -5,7 +5,6 @@ use diesel::prelude::*;
use diesel; use diesel;
use diesel::dsl::exists; use diesel::dsl::exists;
use diesel::query_builder::AsQuery;
use diesel::select; use diesel::select;
use database::connection; use database::connection;
@@ -41,7 +40,7 @@ pub fn get_podcasts_filter(filter_ids: &[i32]) -> Result<Vec<Podcast>, DataError
podcast podcast
.order(title.asc()) .order(title.asc())
.filter(id.ne_all(filter_ids)) .filter(id.ne_any(filter_ids))
.load::<Podcast>(&con) .load::<Podcast>(&con)
.map_err(From::from) .map_err(From::from)
} }
@@ -103,20 +102,6 @@ pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_episode_widget_from_rowid(ep_id: i32) -> Result<EpisodeWidgetQuery, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
))
.filter(rowid.eq(ep_id))
.get_result::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> { pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>, DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
@@ -133,17 +118,24 @@ pub fn get_episodes_widgets_filter_limit(
filter_ids: &[i32], filter_ids: &[i32],
limit: u32, limit: u32,
) -> Result<Vec<EpisodeWidgetQuery>, DataError> { ) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode::dsl::*; use schema::episode;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
);
episode episode::table
.select(columns) .select((
.order(epoch.desc()) episode::rowid,
.filter(podcast_id.ne_all(filter_ids)) episode::title,
episode::uri,
episode::local_uri,
episode::epoch,
episode::length,
episode::duration,
episode::played,
episode::podcast_id,
))
.order(episode::epoch.desc())
.filter(episode::podcast_id.ne_any(filter_ids))
.limit(i64::from(limit)) .limit(i64::from(limit))
.load::<EpisodeWidgetQuery>(&con) .load::<EpisodeWidgetQuery>(&con)
.map_err(From::from) .map_err(From::from)
@@ -193,17 +185,14 @@ pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
.map_err(From::from) .map_err(From::from)
} }
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> { pub fn get_pd_episodeswidgets(_parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
let columns = (
rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id,
);
episode episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
.select(columns) // .filter(podcast_id.eq(parent.id()))
.filter(podcast_id.eq(parent.id())) // .group_by(epoch)
.order(epoch.desc()) .order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con) .load::<EpisodeWidgetQuery>(&con)
.map_err(From::from) .map_err(From::from)
@@ -360,34 +349,6 @@ pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool, Dat
.map_err(From::from) .map_err(From::from)
} }
/// Check if the `episode table contains any rows
///
/// Return true if `episode` table is populated.
pub fn is_episodes_populated() -> Result<bool, DataError> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.as_query()))
.get_result(&con)
.map_err(From::from)
}
/// Check if the `podcast` table contains any rows
///
/// Return true if `podcast table is populated.
pub fn is_podcasts_populated(filter_ids: &[i32]) -> Result<bool, DataError> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(podcast.filter(id.ne_all(filter_ids))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> { pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<(), DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
@@ -418,7 +379,7 @@ pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize, DataError> {
mod tests { mod tests {
use super::*; use super::*;
use database::*; use database::*;
use pipeline; use pipeline::*;
#[test] #[test]
fn test_update_none_to_played_now() { fn test_update_none_to_played_now() {
@@ -428,7 +389,7 @@ mod tests {
com/InterceptedWithJeremyScahill"; com/InterceptedWithJeremyScahill";
let source = Source::from_url(url).unwrap(); let source = Source::from_url(url).unwrap();
let id = source.id(); let id = source.id();
pipeline::run(vec![source], true).unwrap(); index_single_source(source, true).unwrap();
let pd = get_podcast_from_source_id(id).unwrap(); let pd = get_podcast_from_source_id(id).unwrap();
let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len(); let eps_num = get_pd_unplayed_episodes(&pd).unwrap().len();
+8 -33
View File
@@ -5,30 +5,9 @@ use hyper;
use native_tls; use native_tls;
use rss; use rss;
use url; use url;
use xml;
use std::io; use std::io;
use models::Source;
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
#[derive(Fail, Debug)]
pub struct HttpStatusError {
url: String,
status_code: hyper::StatusCode,
context: String,
}
impl HttpStatusError {
pub fn new(url: String, code: hyper::StatusCode, context: String) -> Self {
HttpStatusError {
url,
status_code: code,
context,
}
}
}
#[derive(Fail, Debug)] #[derive(Fail, Debug)]
pub enum DataError { pub enum DataError {
#[fail(display = "SQL Query failed: {}", _0)] #[fail(display = "SQL Query failed: {}", _0)]
@@ -50,16 +29,18 @@ pub enum DataError {
IOError(#[cause] io::Error), IOError(#[cause] io::Error),
#[fail(display = "RSS Error: {}", _0)] #[fail(display = "RSS Error: {}", _0)]
RssError(#[cause] rss::Error), RssError(#[cause] rss::Error),
#[fail(display = "XML Reader Error: {}", _0)]
XmlReaderError(#[cause] xml::reader::Error),
#[fail(display = "Error: {}", _0)] #[fail(display = "Error: {}", _0)]
Bail(String), Bail(String),
#[fail(display = "{}", _0)] #[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
HttpStatusGeneral(HttpStatusError), HttpStatusError {
#[fail(display = "FIXME: This should be better")] url: String,
F301(Source), status_code: hyper::StatusCode,
context: String,
},
#[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)] #[fail(display = "Error occured while Parsing an Episode. Reason: {}", reason)]
ParseEpisodeError { reason: String, parent_id: i32 }, ParseEpisodeError { reason: String, parent_id: i32 },
#[fail(display = "No Futures where produced to be run.")]
EmptyFuturesList,
#[fail(display = "Episode was not changed and thus skipped.")] #[fail(display = "Episode was not changed and thus skipped.")]
EpisodeNotChanged, EpisodeNotChanged,
} }
@@ -118,12 +99,6 @@ impl From<rss::Error> for DataError {
} }
} }
impl From<xml::reader::Error> for DataError {
fn from(err: xml::reader::Error) -> Self {
DataError::XmlReaderError(err)
}
}
impl From<String> for DataError { impl From<String> for DataError {
fn from(err: String) -> Self { fn from(err: String) -> Self {
DataError::Bail(err) DataError::Bail(err)
+104 -94
View File
@@ -1,15 +1,16 @@
#![cfg_attr(feature = "cargo-clippy", allow(unit_arg))]
//! Index Feeds. //! Index Feeds.
use futures::future::*; use futures::future::*;
use futures::prelude::*; use itertools::{Either, Itertools};
use futures::stream;
use rss; 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, NewPodcast, Podcast}; use models::{NewEpisode, NewPodcast, Podcast};
use pipeline::*;
type InsertUpdate = (Vec<NewEpisode>, Vec<Option<(NewEpisode, i32)>>);
/// Wrapper struct that hold a `Source` id and the `rss::Channel` /// 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.
@@ -25,104 +26,90 @@ pub struct Feed {
impl Feed { impl Feed {
/// Index the contents of the RSS `Feed` into the database. /// Index the contents of the RSS `Feed` into the database.
pub fn index(self) -> impl Future<Item = (), Error = DataError> + Send { pub fn index(self) -> Box<Future<Item = (), Error = DataError> + Send> {
self.parse_podcast_async() let fut = self.parse_podcast_async()
.and_then(|pd| pd.to_podcast()) .and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(pd)) .and_then(move |pd| self.index_channel_items(&pd));
Box::new(fut)
} }
fn parse_podcast(&self) -> NewPodcast { fn parse_podcast(&self) -> NewPodcast {
NewPodcast::new(&self.channel, self.source_id) NewPodcast::new(&self.channel, self.source_id)
} }
fn parse_podcast_async(&self) -> impl Future<Item = NewPodcast, Error = DataError> + Send { fn parse_podcast_async(&self) -> Box<Future<Item = NewPodcast, Error = DataError> + Send> {
ok(self.parse_podcast()) Box::new(ok(self.parse_podcast()))
} }
fn index_channel_items(self, pd: Podcast) -> impl Future<Item = (), Error = DataError> + Send { fn index_channel_items(
let stream = stream::iter_ok::<_, DataError>(self.channel.into_items()); &self,
pd: &Podcast,
// Parse the episodes ) -> Box<Future<Item = (), Error = DataError> + Send> {
let episodes = stream.filter_map(move |item| { let fut = self.get_stuff(pd)
glue(&item, pd.id()) .and_then(|(insert, update)| {
.map_err(|err| error!("Failed to parse an episode: {}", err)) if !insert.is_empty() {
.ok() info!("Indexing {} episodes.", insert.len());
}); if let Err(err) = dbqueries::index_new_episodes(insert.as_slice()) {
error!("Failed batch indexng, Fallign back to individual indexing.");
// Filter errors, Index updatable episodes, return insertables. error!("{}", err);
filter_episodes(episodes) insert.iter().for_each(|ep| {
// Batch index insertable episodes. if let Err(err) = ep.index() {
.and_then(|eps| ok(batch_insert_episodes(&eps))) error!("Failed to index episode: {:?}.", ep.title());
} error!("{}", err);
} };
})
fn glue(item: &rss::Item, id: i32) -> Result<IndexState<NewEpisode>, DataError> { }
NewEpisodeMinimal::new(item, id).and_then(move |ep| determine_ep_state(ep, item)) }
} Ok((insert, update))
})
fn determine_ep_state( .map(|(_, update)| {
ep: NewEpisodeMinimal, if !update.is_empty() {
item: &rss::Item, info!("Updating {} episodes.", update.len());
) -> Result<IndexState<NewEpisode>, DataError> { // see get_stuff for more
// Check if feed exists update
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?; .into_iter()
.filter_map(|x| x)
if !exists { .for_each(|(ref ep, rowid)| {
Ok(IndexState::Index(ep.into_new_episode(item))) if let Err(err) = ep.update(rowid) {
} else { error!("Failed to index episode: {:?}.", ep.title());
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?; error!("{}", err);
let rowid = old.rowid(); };
})
if ep != old { }
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
} else {
Ok(IndexState::NotChanged)
}
}
}
fn filter_episodes<'a, S>(
stream: S,
) -> impl Future<Item = Vec<NewEpisode>, Error = DataError> + Send + 'a
where
S: Stream<Item = IndexState<NewEpisode>, Error = DataError> + Send + 'a,
{
stream.filter_map(|state| match state {
IndexState::NotChanged => None,
// Update individual rows, and filter them
IndexState::Update((ref ep, rowid)) => {
ep.update(rowid)
.map_err(|err| error!("{}", err))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.ok();
None
},
IndexState::Index(s) => Some(s),
})
// only Index is left, collect them for batch index
.collect()
}
fn batch_insert_episodes(episodes: &[NewEpisode]) {
if episodes.is_empty() {
return;
};
info!("Indexing {} episodes.", episodes.len());
dbqueries::index_new_episodes(episodes)
.map_err(|err| {
error!("Failed batch indexng: {}", err);
info!("Fallign back to individual indexing.");
})
.unwrap_or_else(|_| {
episodes.iter().for_each(|ep| {
ep.index()
.map_err(|err| error!("Error: {}.", err))
.map_err(|_| error!("Failed to index episode: {:?}.", ep.title()))
.ok();
}); });
})
Box::new(fut)
}
fn get_stuff(
&self,
pd: &Podcast,
) -> Box<Future<Item = InsertUpdate, Error = DataError> + Send> {
let (insert, update): (Vec<_>, Vec<_>) = self.channel
.items()
.into_iter()
.map(|item| glue_async(item, pd.id()))
// This is sort of ugly but I think it's cheaper than pushing None
// to updated and filtering it out later.
// Even though we already map_filter in index_channel_items.
// I am not sure what the optimizations are on match vs allocating None.
.map(|fut| {
fut.and_then(|x| match x {
IndexState::NotChanged => Err(DataError::EpisodeNotChanged),
_ => Ok(x),
})
})
.flat_map(|fut| fut.wait())
.partition_map(|state| match state {
IndexState::Index(e) => Either::Left(e),
IndexState::Update(e) => Either::Right(Some(e)),
// This should never occur
IndexState::NotChanged => Either::Right(None),
});
Box::new(ok((insert, update)))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -130,10 +117,10 @@ mod tests {
use rss::Channel; use rss::Channel;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
use Source;
use database::truncate_db; use database::truncate_db;
use dbqueries; use dbqueries;
use utils::get_feed; use utils::get_feed;
use Source;
use std::fs; use std::fs;
use std::io::BufReader; use std::io::BufReader;
@@ -215,8 +202,31 @@ mod tests {
let feed = get_feed(path, 42); let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap(); let pd = feed.parse_podcast().to_podcast().unwrap();
feed.index_channel_items(pd).wait().unwrap(); feed.index_channel_items(&pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1); assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43); assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
} }
#[test]
fn test_feed_get_stuff() {
truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(43, insert.len());
assert_eq!(0, update.len());
feed.index().wait().unwrap();
let path = "tests/feeds/2018-02-03-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(4, insert.len());
assert_eq!(43, update.len());
}
} }
+25 -38
View File
@@ -1,25 +1,22 @@
#![recursion_limit = "1024"] #![recursion_limit = "1024"]
#![allow(unknown_lints)]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))] #![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 = "cargo-clippy", allow(blacklisted_name))]
#![cfg_attr( #![cfg_attr(feature = "clippy",
feature = "clippy", warn(option_unwrap_used, result_unwrap_used, print_stdout,
warn( wrong_pub_self_convention, mut_mut, non_ascii_literal, similar_names,
option_unwrap_used, result_unwrap_used, print_stdout, wrong_pub_self_convention, mut_mut, unicode_not_nfc, enum_glob_use, if_not_else, items_after_statements,
non_ascii_literal, similar_names, unicode_not_nfc, enum_glob_use, if_not_else, used_underscore_binding))]
items_after_statements, used_underscore_binding #![allow(unknown_lints)]
) #![deny(bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
)] non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals,
#![warn( path_statements, patterns_in_fns_without_body, plugin_as_library, private_in_public,
bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership, private_no_mangle_fns, private_no_mangle_statics, safe_extern_statics,
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals, path_statements, unconditional_recursion, unions_with_drop_fields, unused_allocation, unused_comparisons,
patterns_in_fns_without_body, plugin_as_library, private_in_public, private_no_mangle_fns, unused_parens, while_true)]
private_no_mangle_statics, safe_extern_statics, unconditional_recursion, #![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts)]
unions_with_drop_fields, unused_allocation, unused_comparisons, unused_parens, while_true, #![deny(unused_extern_crates, unused)]
missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts,
unused_extern_crates, unused // #![feature(conservative_impl_trait)]
)]
#![deny(warnings)]
//! FIXME: Docs //! FIXME: Docs
@@ -27,10 +24,6 @@
#[macro_use] #[macro_use]
extern crate pretty_assertions; extern crate pretty_assertions;
#[cfg(test)]
#[macro_use]
extern crate maplit;
#[macro_use] #[macro_use]
extern crate derive_builder; extern crate derive_builder;
#[macro_use] #[macro_use]
@@ -51,37 +44,31 @@ extern crate chrono;
extern crate futures; extern crate futures;
extern crate hyper; extern crate hyper;
extern crate hyper_tls; extern crate hyper_tls;
extern crate itertools;
extern crate native_tls; extern crate native_tls;
extern crate num_cpus; extern crate num_cpus;
extern crate rayon; extern crate rayon;
extern crate rayon_futures;
extern crate rfc822_sanitizer; extern crate rfc822_sanitizer;
extern crate rss; extern crate rss;
extern crate tokio_core; extern crate tokio_core;
extern crate url; extern crate url;
extern crate xdg; extern crate xdg;
extern crate xml;
pub mod database;
#[allow(missing_docs)] #[allow(missing_docs)]
pub mod dbqueries; pub mod dbqueries;
#[allow(missing_docs)] #[allow(missing_docs)]
pub mod errors; pub mod errors;
mod feed;
pub(crate) mod models;
pub mod opml;
mod parser;
pub mod pipeline;
mod schema;
pub mod utils; pub mod utils;
pub mod database;
pub mod pipeline;
pub(crate) mod models;
mod feed;
mod parser;
mod schema;
pub use feed::{Feed, FeedBuilder}; pub use feed::{Feed, FeedBuilder};
pub use models::Save;
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source}; pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
pub use models::Save;
// 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. /// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
+7 -13
View File
@@ -1,7 +1,7 @@
use chrono::prelude::*; use chrono::prelude::*;
use diesel; use diesel;
use diesel::prelude::*;
use diesel::SaveChangesDsl; use diesel::SaveChangesDsl;
use diesel::prelude::*;
use database::connection; use database::connection;
use errors::DataError; use errors::DataError;
@@ -31,12 +31,10 @@ pub struct Episode {
podcast_id: i32, podcast_id: i32,
} }
impl Save<Episode> for Episode { impl Save<Episode, DataError> for Episode {
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<Episode, Self::Error> { fn save(&self) -> Result<Episode, DataError> {
let db = connection(); let db = connection();
let tempdb = db.get()?; let tempdb = db.get()?;
@@ -226,12 +224,10 @@ impl From<Episode> for EpisodeWidgetQuery {
} }
} }
impl Save<usize> for EpisodeWidgetQuery { impl Save<usize, DataError> for EpisodeWidgetQuery {
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, DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
@@ -367,12 +363,10 @@ pub struct EpisodeCleanerQuery {
played: Option<i32>, played: Option<i32>,
} }
impl Save<usize> for EpisodeCleanerQuery { impl Save<usize, DataError> for EpisodeCleanerQuery {
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, DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
+8 -16
View File
@@ -30,30 +30,22 @@ pub enum IndexState<T> {
NotChanged, NotChanged,
} }
pub trait Insert<T> { pub trait Insert<T, E> {
type Error; fn insert(&self) -> Result<T, E>;
fn insert(&self) -> Result<T, Self::Error>;
} }
pub trait Update<T> { pub trait Update<T, E> {
type Error; fn update(&self, i32) -> Result<T, E>;
fn update(&self, i32) -> Result<T, Self::Error>;
} }
// This might need to change in the future // This might need to change in the future
pub trait Index<T>: Insert<T> + Update<T> { pub trait Index<T, E>: Insert<T, E> + Update<T, E> {
type Error; fn index(&self) -> Result<T, E>;
fn index(&self) -> Result<T, <Self as Index<T>>::Error>;
} }
/// FIXME: DOCS /// FIXME: DOCS
pub trait Save<T> { pub trait Save<T, E> {
/// The Error type to be returned.
type Error;
/// Helper method to easily save/"sync" current state of a diesel model to /// Helper method to easily save/"sync" current state of a diesel model to
/// the Database. /// the Database.
fn save(&self) -> Result<T, Self::Error>; fn save(&self) -> Result<T, E>;
} }
+7 -20
View File
@@ -43,9 +43,7 @@ impl From<NewEpisodeMinimal> for NewEpisode {
} }
} }
impl Insert<()> for NewEpisode { impl Insert<(), DataError> for NewEpisode {
type Error = DataError;
fn insert(&self) -> Result<(), DataError> { fn insert(&self) -> Result<(), DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
@@ -60,9 +58,7 @@ impl Insert<()> for NewEpisode {
} }
} }
impl Update<()> for NewEpisode { impl Update<(), DataError> for NewEpisode {
type Error = DataError;
fn update(&self, episode_id: i32) -> Result<(), DataError> { fn update(&self, episode_id: i32) -> Result<(), DataError> {
use schema::episode::dsl::*; use schema::episode::dsl::*;
let db = connection(); let db = connection();
@@ -77,9 +73,7 @@ impl Update<()> for NewEpisode {
} }
} }
impl Index<()> for NewEpisode { impl Index<(), DataError> for NewEpisode {
type Error = DataError;
// 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> {
@@ -203,9 +197,9 @@ impl NewEpisodeMinimal {
let guid = item.guid().map(|s| s.value().trim().to_owned()); let guid = item.guid().map(|s| s.value().trim().to_owned());
let uri = item.enclosure() let uri = item.enclosure()
.map(|s| url_cleaner(s.url().trim())) .map(|s| url_cleaner(s.url()))
// 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)));
// If url is still None return an Error as this behaviour is // If url is still None return an Error as this behaviour is
// compliant with the RSS Spec. // compliant with the RSS Spec.
@@ -240,14 +234,7 @@ impl NewEpisodeMinimal {
// TODO: TryInto is stabilizing in rustc v1.26! // TODO: TryInto is stabilizing in rustc v1.26!
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 length = item.enclosure().and_then(|x| x.length().parse().ok());
let description = item.description().and_then(|s| { let description = item.description().map(|s| ammonia::clean(s));
let sanitized_html = ammonia::Builder::new()
// Remove `rel` attributes from `<a>` tags
.link_rel(None)
.clean(s.trim())
.to_string();
Some(sanitized_html)
});
NewEpisodeBuilder::default() NewEpisodeBuilder::default()
.title(self.title) .title(self.title)
@@ -293,8 +280,8 @@ impl NewEpisodeMinimal {
mod tests { mod tests {
use database::truncate_db; use database::truncate_db;
use dbqueries; use dbqueries;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use models::*; use models::*;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use rss::Channel; use rss::Channel;
+10 -20
View File
@@ -4,8 +4,8 @@ use diesel::prelude::*;
use rss; use rss;
use errors::DataError; use errors::DataError;
use models::Podcast;
use models::{Index, Insert, Update}; use models::{Index, Insert, Update};
use models::Podcast;
use schema::podcast; use schema::podcast;
use database::connection; use database::connection;
@@ -26,10 +26,8 @@ pub(crate) struct NewPodcast {
source_id: i32, source_id: i32,
} }
impl Insert<()> for NewPodcast { impl Insert<(), DataError> for NewPodcast {
type Error = DataError; fn insert(&self) -> Result<(), DataError> {
fn insert(&self) -> Result<(), Self::Error> {
use schema::podcast::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -42,10 +40,8 @@ impl Insert<()> for NewPodcast {
} }
} }
impl Update<()> for NewPodcast { impl Update<(), DataError> for NewPodcast {
type Error = DataError; fn update(&self, podcast_id: i32) -> Result<(), DataError> {
fn update(&self, podcast_id: i32) -> Result<(), Self::Error> {
use schema::podcast::dsl::*; use schema::podcast::dsl::*;
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -61,9 +57,7 @@ impl Update<()> for NewPodcast {
// 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 NewPodcast { impl Index<(), DataError> for NewPodcast {
type Error = DataError;
fn index(&self) -> Result<(), DataError> { fn index(&self) -> Result<(), DataError> {
let exists = dbqueries::podcast_exists(self.source_id)?; let exists = dbqueries::podcast_exists(self.source_id)?;
@@ -94,20 +88,16 @@ impl NewPodcast {
/// Parses a `rss::Channel` into a `NewPodcast` Struct. /// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast { 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 description = ammonia::Builder::new() let description = ammonia::clean(chan.description().trim());
// Remove `rel` attributes from `<a>` tags let link = url_cleaner(chan.link());
.link_rel(None)
.clean(chan.description().trim())
.to_string();
// Try to get the itunes img first // Try to get the itunes img first
let itunes_img = chan.itunes_ext() let itunes_img = chan.itunes_ext()
.and_then(|s| s.image().map(|url| url.trim())) .and_then(|s| s.image())
.map(|s| s.to_owned()); .map(|s| s.to_owned());
// 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().to_owned()));
NewPodcastBuilder::default() NewPodcastBuilder::default()
.title(title) .title(title)
+2
View File
@@ -1,3 +1,5 @@
#![allow(unused_mut)]
use diesel; use diesel;
use diesel::prelude::*; use diesel::prelude::*;
use url::Url; use url::Url;
+2 -4
View File
@@ -25,12 +25,10 @@ pub struct Podcast {
source_id: i32, source_id: i32,
} }
impl Save<Podcast> for Podcast { impl Save<Podcast, DataError> for Podcast {
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<Podcast, Self::Error> { fn save(&self) -> Result<Podcast, DataError> {
let db = connection(); let db = connection();
let tempdb = db.get()?; let tempdb = db.get()?;
+46 -50
View File
@@ -3,23 +3,20 @@ use diesel::SaveChangesDsl;
use rss::Channel; use rss::Channel;
use url::Url; use url::Url;
use hyper::client::HttpConnector;
use hyper::header::{
ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified, Location, UserAgent,
};
use hyper::{Client, Method, Request, Response, StatusCode, Uri}; use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper::client::HttpConnector;
use hyper::header::{ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified,
Location, UserAgent};
use hyper_tls::HttpsConnector; use hyper_tls::HttpsConnector;
// use futures::future::ok; // use futures::future::ok;
use futures::future::{loop_fn, Future, Loop};
use futures::prelude::*; use futures::prelude::*;
use database::connection; use database::connection;
use errors::*; use errors::DataError;
use feed::{Feed, FeedBuilder}; use feed::{Feed, FeedBuilder};
use models::{NewSource, Save}; use models::{NewSource, Save};
use schema::source; use schema::source;
use USER_AGENT;
use std::str::FromStr; use std::str::FromStr;
@@ -35,12 +32,10 @@ pub struct Source {
http_etag: Option<String>, http_etag: Option<String>,
} }
impl Save<Source> for Source { impl Save<Source, DataError> for Source {
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<Source, Self::Error> { fn save(&self) -> Result<Source, DataError> {
let db = connection(); let db = connection();
let con = db.get()?; let con = db.get()?;
@@ -107,7 +102,11 @@ impl Source {
} }
fn make_err(self, context: &str, code: StatusCode) -> DataError { fn make_err(self, context: &str, code: StatusCode) -> DataError {
DataError::HttpStatusGeneral(HttpStatusError::new(self.uri, code, context.into())) DataError::HttpStatusError {
url: self.uri,
status_code: code,
context: context.into(),
}
} }
// TODO match on more stuff // TODO match on more stuff
@@ -120,7 +119,7 @@ impl Source {
// 408: Timeout // 408: Timeout
// 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<(Self, Response), DataError> {
self.update_etag(&res)?; self.update_etag(&res)?;
let code = res.status(); let code = res.status();
@@ -129,7 +128,7 @@ impl Source {
StatusCode::MovedPermanently => { StatusCode::MovedPermanently => {
error!("Feed was moved permanently."); error!("Feed was moved permanently.");
self.handle_301(&res)?; self.handle_301(&res)?;
return Err(DataError::F301(self)); return Err(self.make_err("301: Feed was moved permanently.", code));
} }
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."), StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."), StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
@@ -140,7 +139,7 @@ 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((self, res))
} }
fn handle_301(&mut self, res: &Response) -> Result<(), DataError> { fn handle_301(&mut self, res: &Response) -> Result<(), DataError> {
@@ -152,6 +151,8 @@ impl Source {
self.last_modified = None; self.last_modified = None;
self.save()?; self.save()?;
info!("Feed url was updated succesfully."); info!("Feed url was updated succesfully.");
// TODO: Refresh in place instead of next time, Not a priority.
info!("New content will be fetched with the next refesh.");
} }
Ok(()) Ok(())
@@ -176,34 +177,21 @@ impl Source {
// Refactor into TryInto once it lands on stable. // Refactor into TryInto once it lands on stable.
pub fn into_feed( pub fn into_feed(
self, self,
client: Client<HttpsConnector<HttpConnector>>, client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool, ignore_etags: bool,
) -> impl Future<Item = Feed, Error = DataError> { ) -> Box<Future<Item = Feed, Error = DataError>> {
let id = self.id(); let id = self.id();
let response = loop_fn(self, move |source| { let feed = self.request_constructor(client, ignore_etags)
source .and_then(move |(_, res)| response_to_channel(res))
.request_constructor(&client.clone(), ignore_etags)
.then(|res| match res {
Ok(response) => Ok(Loop::Break(response)),
Err(err) => match err {
DataError::F301(s) => {
info!("Following redirect...");
Ok(Loop::Continue(s))
}
e => Err(e),
},
})
});
response
.and_then(response_to_channel)
.and_then(move |chan| { .and_then(move |chan| {
FeedBuilder::default() FeedBuilder::default()
.channel(chan) .channel(chan)
.source_id(id) .source_id(id)
.build() .build()
.map_err(From::from) .map_err(From::from)
}) });
Box::new(feed)
} }
// TODO: make ignore_etags an Enum for better ergonomics. // TODO: make ignore_etags an Enum for better ergonomics.
@@ -212,43 +200,51 @@ impl Source {
self, self,
client: &Client<HttpsConnector<HttpConnector>>, client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool, ignore_etags: bool,
) -> impl Future<Item = Response, Error = DataError> { ) -> Box<Future<Item = (Self, 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();
let mut req = Request::new(Method::Get, uri); let mut req = Request::new(Method::Get, uri);
// Set the UserAgent cause ppl still seem to check it for some reason... // Set the user agent as a fix for issue #53
req.headers_mut().set(UserAgent::new(USER_AGENT)); // TODO: keep this in sync with tor-browser releases
req.headers_mut().set(UserAgent::new(
"Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0",
));
if !ignore_etags { if !ignore_etags {
if let Some(etag) = self.http_etag() { if let Some(foo) = self.http_etag() {
let tag = vec![EntityTag::new(true, etag.to_owned())]; req.headers_mut().set(IfNoneMatch::Items(vec![
req.headers_mut().set(IfNoneMatch::Items(tag)); EntityTag::new(true, foo.to_owned()),
]));
} }
if let Some(lmod) = self.last_modified() { if let Some(foo) = self.last_modified() {
if let Ok(date) = lmod.parse::<HttpDate>() { if let Ok(x) = foo.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(date)); req.headers_mut().set(IfModifiedSince(x));
} }
} }
} }
client let work = client
.request(req) .request(req)
.map_err(From::from) .map_err(From::from)
.and_then(move |res| self.match_status(res)) // TODO: tail recursion loop that would follow redirects directly
.and_then(move |res| self.match_status(res));
Box::new(work)
} }
} }
#[allow(needless_pass_by_value)] #[allow(needless_pass_by_value)]
fn response_to_channel(res: Response) -> impl Future<Item = Channel, Error = DataError> + Send { fn response_to_channel(res: Response) -> Box<Future<Item = Channel, Error = DataError> + Send> {
res.body() let chan = res.body()
.concat2() .concat2()
.map(|x| x.into_iter()) .map(|x| x.into_iter())
.map_err(From::from) .map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>()) .map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned()) .map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| Channel::from_str(&buf).map_err(From::from)) .and_then(|buf| Channel::from_str(&buf).map_err(From::from));
Box::new(chan)
} }
#[cfg(test)] #[cfg(test)]
@@ -273,7 +269,7 @@ mod tests {
let source = Source::from_url(url).unwrap(); let source = Source::from_url(url).unwrap();
let id = source.id(); let id = source.id();
let feed = source.into_feed(client, true); let feed = source.into_feed(&client, true);
let feed = core.run(feed).unwrap(); let 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);
-167
View File
@@ -1,167 +0,0 @@
//! FIXME: Docs
// #![allow(unused)]
use errors::DataError;
use models::Source;
use xml::reader;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::Path;
// use std::fs::{File, OpenOptions};
// use std::io::BufReader;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
// FIXME: Make it a Diesel model
/// Represents an `outline` xml element as per the `OPML` [specification][spec]
/// not `RSS` related sub-elements are ommited.
///
/// [spec]: http://dev.opml.org/spec2.html
pub struct Opml {
title: String,
description: String,
url: String,
}
/// Import feed url's from a `R` into the `Source` table.
// TODO: Write test
pub fn import_to_db<R: Read>(reader: R) -> Result<Vec<Source>, reader::Error> {
let feeds = extract_sources(reader)?
.iter()
.map(|opml| Source::from_url(&opml.url))
.filter_map(|s| {
if let Err(ref err) = s {
let txt = "If you think this might be a bug please consider filling a report over \
at https://gitlab.gnome.org/World/hammond/issues/new";
error!("Failed to import a Show: {}", err);
error!("{}", txt);
}
s.ok()
})
.collect();
Ok(feeds)
}
/// Open a File from `P`, try to parse the OPML then insert the Feeds in the database and
/// return the new `Source`s
// TODO: Write test
pub fn import_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Source>, DataError> {
let content = fs::read(path)?;
import_to_db(content.as_slice()).map_err(From::from)
}
/// Extracts the `outline` elemnts from a reader `R` and returns a `HashSet` of `Opml` structs.
pub fn extract_sources<R: Read>(reader: R) -> Result<HashSet<Opml>, reader::Error> {
let mut list = HashSet::new();
let parser = reader::EventReader::new(reader);
parser
.into_iter()
.map(|e| match e {
Ok(reader::XmlEvent::StartElement {
name, attributes, ..
}) => {
if name.local_name == "outline" {
let mut title = String::new();
let mut url = String::new();
let mut description = String::new();
attributes.into_iter().for_each(|attribute| {
match attribute.name.local_name.as_str() {
"title" => title = attribute.value,
"xmlUrl" => url = attribute.value,
"description" => description = attribute.value,
_ => {}
}
});
let feed = Opml {
title,
description,
url,
};
list.insert(feed);
}
Ok(())
}
Err(err) => Err(err),
_ => Ok(()),
})
.collect::<Result<Vec<_>, reader::Error>>()?;
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
#[test]
fn test_extract() {
let int_title = String::from("Intercepted with Jeremy Scahill");
let int_url = String::from("https://feeds.feedburner.com/InterceptedWithJeremyScahill");
let int_desc =
String::from(
"The people behind The Intercepts fearless reporting and incisive \
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and others—discuss the \
crucial issues of our time: national security, civil liberties, foreign policy, \
and criminal justice. Plus interviews with artists, thinkers, and newsmakers \
who challenge our preconceptions about the world we live in.",
);
let dec_title = String::from("Deconstructed with Mehdi Hasan");
let dec_url = String::from("https://rss.prod.firstlook.media/deconstructed/podcast.rss");
let dec_desc = String::from(
"Journalist Mehdi Hasan is known around the world for his televised takedowns of \
presidents and prime ministers. In this new podcast from The Intercept, Mehdi \
unpacks a game-changing news event of the week while challenging the conventional \
wisdom. As a Brit, a Muslim and an immigrant based in Donald Trump's Washington \
D.C., Mehdi gives a refreshingly provocative perspective on the ups and downs of \
American—and global—politics.",
);
#[cfg_attr(rustfmt, rustfmt_skip)]
let sample1 = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?> \
<opml version=\"2.0\"> \
<head> \
<title>Test OPML File</title> \
<dateCreated>{}</dateCreated> \
<docs>http://www.opml.org/spec2</docs> \
</head> \
<body> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
<outline type=\"rss\" title=\"{}\" description=\"{}\" xmlUrl=\"{}\"/> \
</body> \
</opml>",
Local::now().format("%a, %d %b %Y %T %Z"),
int_title,
int_desc,
int_url,
dec_title,
dec_desc,
dec_url,
);
let map = hashset![
Opml {
title: int_title,
description: int_desc,
url: int_url
},
Opml {
title: dec_title,
description: dec_desc,
url: dec_url
},
];
assert_eq!(extract_sources(sample1.as_bytes()).unwrap(), map);
}
}
+139 -70
View File
@@ -2,117 +2,186 @@
//! Docs. //! Docs.
use futures::future::*; use futures::future::*;
use futures::prelude::*; // use futures::prelude::*;
use futures::stream::*;
use hyper::client::HttpConnector;
use hyper::Client; use hyper::Client;
use hyper::client::HttpConnector;
use hyper_tls::HttpsConnector; use hyper_tls::HttpsConnector;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
use num_cpus; use num_cpus;
use rayon; use rss;
use rayon_futures::ScopeFutureExt;
use errors::DataError;
use Source; use Source;
use dbqueries;
use errors::DataError;
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
// use std::sync::{Arc, Mutex}; // use std::sync::{Arc, Mutex};
// http://gtk-rs.org/tuto/closures
#[macro_export]
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
type HttpsClient = Client<HttpsConnector<HttpConnector>>;
/// The pipline to be run for indexing and updating a Podcast feed that originates from /// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`. /// `Source.uri`.
/// ///
/// Messy temp diagram: /// Messy temp diagram:
/// Source -> GET Request -> Update Etags -> Check Status -> Parse `xml/Rss` -> /// Source -> GET Request -> Update Etags -> Check Status -> Parse xml/Rss ->
/// 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<S: IntoIterator<Item = Source>>(
sources: S, sources: S,
ignore_etags: bool, ignore_etags: bool,
client: &HttpsClient, tokio_core: &mut Core,
) -> impl Future<Item = Vec<()>, Error = DataError> + 'a client: Client<HttpsConnector<HttpConnector>>,
where ) -> Result<(), DataError> {
S: Stream<Item = Source, Error = DataError> + 'a, let list: Vec<_> = sources
{ .into_iter()
sources .map(move |s| s.into_feed(&client, ignore_etags))
.and_then(clone!(client => move |s| s.into_feed(client.clone(), ignore_etags))) .map(|fut| fut.and_then(|feed| feed.index()))
.and_then(|feed| rayon::scope(|s| s.spawn_future(feed.index()))) .map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
// the stream will stop at the first error so .collect();
// we ensure that everything will succeded regardless.
.map_err(|err| error!("Error: {}", err)) if list.is_empty() {
.then(|_| ok::<(), DataError>(())) return Err(DataError::EmptyFuturesList);
.collect() }
// Thats not really concurrent yet I think.
tokio_core.run(collect_futures(list))?;
Ok(())
} }
/// 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.
pub fn run<S>(sources: S, ignore_etags: bool) -> Result<(), DataError> pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
where if sources.is_empty() {
S: IntoIterator<Item = Source>, return Ok(());
{ }
let mut core = Core::new()?; let mut core = Core::new()?;
let handle = core.handle(); let handle = core.handle();
let client = Client::configure() let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?) .connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle); .build(&handle);
let stream = iter_ok::<_, DataError>(sources); pipeline(sources, ignore_etags, &mut core, client)
let p = pipeline(stream, ignore_etags, &client); }
core.run(p).map(|_| ())
/// Docs
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
let work = s.into_feed(&client, ignore_etags)
.and_then(move |feed| feed.index())
.map(|_| ());
core.run(work)
}
fn determine_ep_state(
ep: NewEpisodeMinimal,
item: &rss::Item,
) -> Result<IndexState<NewEpisode>, DataError> {
// Check if feed exists
let exists = dbqueries::episode_exists(ep.title(), ep.podcast_id())?;
if !exists {
Ok(IndexState::Index(ep.into_new_episode(item)))
} else {
let old = dbqueries::get_episode_minimal_from_pk(ep.title(), ep.podcast_id())?;
let rowid = old.rowid();
if ep != old {
Ok(IndexState::Update((ep.into_new_episode(item), rowid)))
} else {
Ok(IndexState::NotChanged)
}
}
}
pub(crate) fn glue_async<'a>(
item: &'a rss::Item,
id: i32,
) -> Box<Future<Item = IndexState<NewEpisode>, Error = DataError> + 'a> {
Box::new(
result(NewEpisodeMinimal::new(item, id)).and_then(move |ep| determine_ep_state(ep, item)),
)
}
// Weird magic from #rust irc channel
// kudos to remexre
/// FIXME: Docs
#[cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
pub fn collect_futures<F>(
futures: Vec<F>,
) -> Box<Future<Item = Vec<Result<F::Item, F::Error>>, Error = DataError>>
where
F: 'static + Future,
<F as Future>::Item: 'static,
<F as Future>::Error: 'static,
{
Box::new(loop_fn((futures, vec![]), |(futures, mut done)| {
select_all(futures).then(|r| {
let (r, rest) = match r {
Ok((r, _, rest)) => (Ok(r), rest),
Err((r, _, rest)) => (Err(r), rest),
};
done.push(r);
if rest.is_empty() {
Ok(Loop::Break(done))
} else {
Ok(Loop::Continue((rest, done)))
}
})
}))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use database::truncate_db;
use dbqueries;
use Source; use Source;
use database::truncate_db;
// (path, url) tuples. // (path, url) tuples.
const URLS: &[&str] = &[ const URLS: &[(&str, &str)] = {
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\ &[
com/InterceptedWithJeremyScahill", (
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged", "tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff", "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars", com/InterceptedWithJeremyScahill",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\ ),
com/feed/podcast", (
]; "tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test] #[test]
/// Insert feeds and update/index them. /// Insert feeds and update/index them.
fn test_pipeline() { fn test_pipeline() {
truncate_db().unwrap(); truncate_db().unwrap();
let bad_url = "https://gitlab.gnome.org/World/hammond.atom"; URLS.iter().for_each(|&(_, url)| {
// if a stream returns error/None it stops
// bad we want to parse all feeds regardless if one fails
Source::from_url(bad_url).unwrap();
URLS.iter().for_each(|url| {
// Index the urls into the source table. // Index the urls into the source table.
Source::from_url(url).unwrap(); Source::from_url(url).unwrap();
}); });
let sources = dbqueries::get_sources().unwrap(); let sources = dbqueries::get_sources().unwrap();
run(sources, true).unwrap(); run(sources, true).unwrap();
@@ -121,7 +190,7 @@ mod tests {
run(sources, true).unwrap(); 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().unwrap().len(), 6); assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5); assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354); assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
} }
+10 -9
View File
@@ -28,10 +28,10 @@ fn download_checker() -> Result<(), DataError> {
}) })
.for_each(|ep| { .for_each(|ep| {
ep.set_local_uri(None); ep.set_local_uri(None);
ep.save() if let Err(err) = ep.save() {
.map_err(|err| error!("{}", err)) error!("Error while trying to update episode: {:#?}", ep);
.map_err(|_| error!("Error while trying to update episode: {:#?}", ep)) error!("{}", err);
.ok(); };
}); });
Ok(()) Ok(())
@@ -48,11 +48,12 @@ fn played_cleaner(cleanup_date: DateTime<Utc>) -> Result<(), DataError> {
.for_each(|ep| { .for_each(|ep| {
let limit = ep.played().unwrap(); let limit = ep.played().unwrap();
if now_utc > limit { if now_utc > limit {
delete_local_content(ep) if let Err(err) = delete_local_content(ep) {
.map(|_| info!("Episode {:?} was deleted succesfully.", ep.local_uri())) error!("Error while trying to delete file: {:?}", ep.local_uri());
.map_err(|err| error!("Error: {}", err)) error!("{}", err);
.map_err(|_| error!("Failed to delete file: {:?}", ep.local_uri())) } else {
.ok(); info!("Episode {:?} was deleted succesfully.", ep.local_uri());
};
} }
}); });
Ok(()) Ok(())
+1 -1
View File
@@ -6,7 +6,7 @@ workspace = "../"
[dependencies] [dependencies]
error-chain = "0.11.0" error-chain = "0.11.0"
hyper = "0.11.27" hyper = "0.11.24"
log = "0.4.1" log = "0.4.1"
mime_guess = "1.8.4" mime_guess = "1.8.4"
reqwest = "0.8.5" reqwest = "0.8.5"
+2 -2
View File
@@ -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 hammond_data::xdg_dirs::HAMMOND_CACHE;
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save}; use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
use hammond_data::xdg_dirs::HAMMOND_CACHE;
// use failure::Error; // use failure::Error;
use errors::DownloadError; use errors::DownloadError;
@@ -234,9 +234,9 @@ pub fn cache_image(pd: &PodcastCoverQuery) -> Result<String, DownloadError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use hammond_data::Source;
use hammond_data::dbqueries; use hammond_data::dbqueries;
use hammond_data::pipeline; use hammond_data::pipeline;
use hammond_data::Source;
use std::fs; use std::fs;
+2 -3
View File
@@ -1,8 +1,7 @@
#![recursion_limit = "1024"] #![recursion_limit = "1024"]
#![warn(unused_extern_crates, unused)] #![deny(unused_extern_crates, unused)]
#![allow(unknown_lints)] #![allow(unknown_lints)]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name, option_map_unit_fn))] #![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
#![deny(warnings)]
extern crate failure; extern crate failure;
#[macro_use] #[macro_use]
+5 -5
View File
@@ -6,7 +6,7 @@ version = "0.1.0"
workspace = "../" workspace = "../"
[dependencies] [dependencies]
chrono = "0.4.2" chrono = "0.4.1"
crossbeam-channel = "0.1.2" crossbeam-channel = "0.1.2"
gdk = "0.8.0" gdk = "0.8.0"
gdk-pixbuf = "0.4.0" gdk-pixbuf = "0.4.0"
@@ -17,19 +17,19 @@ log = "0.4.1"
loggerv = "0.7.1" loggerv = "0.7.1"
open = "1.2.1" open = "1.2.1"
rayon = "1.0.1" rayon = "1.0.1"
send-cell = "0.1.3" send-cell = "0.1.2"
url = "1.7.0" url = "1.7.0"
failure = "0.1.1" failure = "0.1.1"
failure_derive = "0.1.1" failure_derive = "0.1.1"
take_mut = "0.2.2" take_mut = "0.2.2"
regex = "1.0.0" regex = "0.2.10"
reqwest = "0.8.5" reqwest = "0.8.5"
serde_json = "1.0.17" serde_json = "1.0.13"
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" } html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
[dependencies.gtk] [dependencies.gtk]
features = ["v3_22"] features = ["v3_22"]
version = "0.4.1" version = "0.4.0"
[dependencies.gio] [dependencies.gio]
features = ["v2_50"] features = ["v2_50"]
+1 -1
View File
@@ -56,11 +56,11 @@ Tobias Bernard
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="label" translatable="yes">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>
<property name="xalign">0</property> <property name="xalign">0</property>
<property name="yalign">0</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
+9 -3
View File
@@ -1,5 +1,5 @@
<?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 Copyright (C) 2017 - 2018
@@ -32,14 +32,13 @@ Tobias Bernard
<!-- interface-authors Jordan Petridis\nTobias Bernard --> <!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container"> <object class="GtkBox" id="container">
<property name="name">container</property> <property name="name">container</property>
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="GtkScrolledWindow" id="scrolled_window"> <object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property> <property name="name">scrolled_window</property>
<property name="height_request">400</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property> <property name="hscrollbar_policy">never</property>
@@ -69,6 +68,8 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox" id="frame_parent"> <object class="GtkBox" id="frame_parent">
<property name="width_request">600</property>
<property name="height_request">-1</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="margin_left">32</property> <property name="margin_left">32</property>
@@ -80,6 +81,7 @@ Tobias Bernard
<property name="spacing">24</property> <property name="spacing">24</property>
<child> <child>
<object class="GtkBox" id="today_box"> <object class="GtkBox" id="today_box">
<property name="visible">True</property>
<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="hexpand">True</property> <property name="hexpand">True</property>
@@ -137,6 +139,7 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox" id="yday_box"> <object class="GtkBox" id="yday_box">
<property name="visible">True</property>
<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="hexpand">True</property> <property name="hexpand">True</property>
@@ -193,6 +196,7 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox" id="week_box"> <object class="GtkBox" id="week_box">
<property name="visible">True</property>
<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="hexpand">True</property> <property name="hexpand">True</property>
@@ -249,6 +253,7 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox" id="month_box"> <object class="GtkBox" id="month_box">
<property name="visible">True</property>
<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="hexpand">True</property> <property name="hexpand">True</property>
@@ -305,6 +310,7 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox" id="rest_box"> <object class="GtkBox" id="rest_box">
<property name="visible">True</property>
<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="hexpand">True</property> <property name="hexpand">True</property>
+5 -32
View File
@@ -1,5 +1,5 @@
<?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 Copyright (C) 2017 - 2018
@@ -168,7 +168,7 @@ Tobias Bernard
</object> </object>
</child> </child>
<child> <child>
<object class="GtkButton" id="back"> <object class="GtkButton" id="back_button">
<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="no_show_all">True</property> <property name="no_show_all">True</property>
@@ -321,11 +321,9 @@ Tobias Bernard
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkModelButton" id="import"> <object class="GtkSeparator">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">False</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Import Shows</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@@ -334,32 +332,7 @@ Tobias Bernard
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkModelButton" id="export"> <object class="GtkModelButton" id="about_button">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Export Shows</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="about">
<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>
+87 -92
View File
@@ -31,17 +31,16 @@ Tobias Bernard
<!-- interface-copyright 2017 - 2018 --> <!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard --> <!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container"> <object class="GtkBox" id="container">
<property name="width_request">400</property>
<property name="height_request">600</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="GtkScrolledWindow" id="scrolled_window"> <object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property> <property name="name">scrolled_window</property>
<property name="width_request">700</property>
<property name="height_request">500</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="vexpand">True</property>
<property name="hscrollbar_policy">never</property> <property name="hscrollbar_policy">never</property>
<child> <child>
<object class="GtkViewport"> <object class="GtkViewport">
@@ -71,26 +70,26 @@ Tobias Bernard
</child> </child>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="width_request">700</property>
<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_left">32</property> <property name="margin_left">32</property>
<property name="margin_right">32</property> <property name="margin_right">32</property>
<property name="margin_top">32</property> <property name="margin_top">32</property>
<property name="margin_bottom">32</property> <property name="margin_bottom">32</property>
<property name="hexpand">True</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="spacing">24</property> <property name="spacing">24</property>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="orientation">vertical</property> <property name="valign">center</property>
<property name="spacing">6</property> <property name="spacing">12</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="pixel_size">256</property> <property name="pixel_size">128</property>
<property name="icon_name">image-x-generic-symbolic</property> <property name="icon_name">image-x-generic-symbolic</property>
</object> </object>
<packing> <packing>
@@ -99,107 +98,107 @@ Tobias Bernard
<property name="position">0</property> <property name="position">0</property>
</packing> </packing>
</child> </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> <child>
<object class="GtkBox"> <object class="GtkBox">
<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">end</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property> <property name="spacing">6</property>
<child> <child type="center">
<object class="GtkMenuButton" id="settings_button"> <object class="GtkLabel" id="description">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">False</property>
<property name="receives_default">True</property> <property name="halign">start</property>
<child> <property name="valign">end</property>
<object class="GtkImage"> <property name="label">foo</property>
<property name="visible">True</property> <property name="use_markup">True</property>
<property name="can_focus">False</property> <property name="wrap">True</property>
<property name="halign">center</property> <property name="wrap_mode">word-char</property>
<property name="valign">center</property> <property name="max_width_chars">90</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
</child>
</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>
</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> <property name="position">1</property>
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkButton" id="unsub_button"> <object class="GtkBox">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">False</property>
<property name="receives_default">True</property> <property name="hexpand">True</property>
<property name="halign">center</property> <property name="spacing">6</property>
<property name="valign">center</property> <child>
<style> <object class="GtkMenuButton" id="settings_button">
<class name="destructive-action"/> <property name="visible">True</property>
</style> <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> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property> <property name="pack_type">end</property>
<property name="position">2</property> <property name="position">0</property>
</packing> </packing>
</child> </child>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">False</property> <property name="fill">True</property>
<property name="position">2</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
</object> </object>
@@ -210,17 +209,13 @@ Sorry, we could not find a description for this Show.</property>
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkFrame"> <object class="GtkFrame" id="episodes">
<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_xalign">0</property> <property name="label_xalign">0</property>
<property name="shadow_type">in</property> <property name="shadow_type">in</property>
<child> <child>
<object class="GtkListBox" id="episodes"> <placeholder/>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child> </child>
<child type="label_item"> <child type="label_item">
<placeholder/> <placeholder/>
@@ -235,7 +230,7 @@ Sorry, we could not find a description for this Show.</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">1</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
@@ -262,7 +257,7 @@ Sorry, we could not find a description for this Show.</property>
</child> </child>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">True</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="position">0</property> <property name="position">0</property>
</packing> </packing>
+17 -8
View File
@@ -1,5 +1,5 @@
<?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 Copyright (C) 2017 - 2018
@@ -39,17 +39,26 @@ Tobias Bernard
<property name="valign">center</property> <property name="valign">center</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="GtkImage" id="pd_cover"> <object class="GtkOverlay">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="halign">center</property> <child>
<property name="valign">center</property> <placeholder/>
<property name="pixel_size">256</property> </child>
<property name="icon_name">image-x-generic-symbolic</property> <child type="overlay">
<property name="icon_size">0</property> <object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="pixel_size">256</property>
<property name="icon_name">image-x-generic-symbolic</property>
<property name="icon_size">0</property>
</object>
</child>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">True</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="position">0</property> <property name="position">0</property>
</packing> </packing>
@@ -12,25 +12,25 @@
desktop written in Rust. desktop written in Rust.
</p> </p>
</description> </description>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url> <url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
<screenshots> <screenshots>
<screenshot> <screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/episodes_view.png</image> <image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/episodes_view.png</image>
<caption>Page 1</caption> <caption>Page 1</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/shows_view.png</image> <image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/shows_view.png</image>
<caption>Page 2</caption> <caption>Page 2</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://gitlab.gnome.org/World/hammond/raw/master/screenshots/show_widget.png</image> <image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/show_widget.png</image>
<caption>Page 3</caption> <caption>Page 3</caption>
</screenshot> </screenshot>
</screenshots> </screenshots>
<releases> <releases>
<release version="0.3.3" date="2018-05-19"/> <release version="0.3.1" date="2018-03-28"/>
</releases> </releases>
<url type="homepage">https://gitlab.gnome.org/World/hammond</url> <url type="homepage">https://gitlab.gnome.org/alatiera/hammond</url>
<update_contact>jpetridis@gnome.org</update_contact> <update_contact>jpetridis@gnome.org</update_contact>
<developer_name>Jordan Petridis and others</developer_name> <developer_name>Jordan Petridis and others</developer_name>
</component> </component>
+109 -68
View File
@@ -3,28 +3,35 @@
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt}; use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags, Settings, SettingsExt};
use glib; use glib;
use gtk; use gtk;
use gtk::prelude::*;
use gtk::SettingsExt as GtkSettingsExt; use gtk::SettingsExt as GtkSettingsExt;
use gtk::prelude::*;
use hammond_data::Podcast; use failure::Error;
use rayon;
use appnotif::{InAppNotification, UndoState}; use hammond_data::{Podcast, Source};
use hammond_data::utils::delete_show;
use appnotif::*;
use headerbar::Header; use headerbar::Header;
use settings::{self, WindowGeometry}; use settings::WindowGeometry;
use stacks::{Content, PopulatedState}; use stacks::Content;
use utils; use utils;
use widgets::{mark_all_notif, remove_show_notif}; use widgets::mark_all_watched;
use std::rc::Rc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration;
#[derive(Debug, Clone)] #[derive(Clone, Debug)]
pub enum Action { pub enum Action {
UpdateSources(Option<Source>),
RefreshAllViews, RefreshAllViews,
RefreshEpisodesView, RefreshEpisodesView,
RefreshEpisodesViewBGR, RefreshEpisodesViewBGR,
RefreshShowsView, RefreshShowsView,
RefreshWidget,
RefreshWidgetIfVis,
ReplaceWidget(Arc<Podcast>), ReplaceWidget(Arc<Podcast>),
RefreshWidgetIfSame(i32), RefreshWidgetIfSame(i32),
ShowWidgetAnimated, ShowWidgetAnimated,
@@ -35,7 +42,6 @@ pub enum Action {
HeaderBarHideUpdateIndicator, HeaderBarHideUpdateIndicator,
MarkAllPlayerNotification(Arc<Podcast>), MarkAllPlayerNotification(Arc<Podcast>),
RemoveShow(Arc<Podcast>), RemoveShow(Arc<Podcast>),
ErrorNotification(String),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -43,8 +49,8 @@ pub struct App {
app_instance: gtk::Application, app_instance: gtk::Application,
window: gtk::Window, window: gtk::Window,
overlay: gtk::Overlay, overlay: gtk::Overlay,
header: Rc<Header>, header: Arc<Header>,
content: Rc<Content>, content: Arc<Content>,
receiver: Receiver<Action>, receiver: Receiver<Action>,
sender: Sender<Action>, sender: Sender<Action>,
settings: Settings, settings: Settings,
@@ -60,27 +66,28 @@ impl App {
glib::set_application_name("Hammond"); glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond")); glib::set_prgname(Some("Hammond"));
let cleanup_date = settings::get_cleanup_date(&settings);
utils::cleanup(cleanup_date);
// Create the main window // Create the main window
let window = gtk::Window::new(gtk::WindowType::Toplevel); let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title("Hammond"); window.set_title("Hammond");
window.connect_delete_event(clone!(application, settings, window => move |_, _| { let app_clone = application.clone();
WindowGeometry::from_window(&window).write(&settings); let window_clone = window.clone();
application.quit(); let settings_clone = settings.clone();
window.connect_delete_event(move |_, _| {
WindowGeometry::from_window(&window_clone).write(&settings_clone);
app_clone.quit();
Inhibit(false) Inhibit(false)
})); });
let (sender, receiver) = channel(); let (sender, receiver) = channel();
// Create a content instance // Create a content instance
let content = let content =
Rc::new(Content::new(sender.clone()).expect("Content Initialization failed.")); Arc::new(Content::new(sender.clone()).expect("Content Initialization failed."));
// Create the headerbar // Create the headerbar
let header = Rc::new(Header::new(&content, &window, &sender)); let header = Arc::new(Header::new(&content, &window, sender.clone()));
// Add the content main stack to the overlay. // Add the content main stack to the overlay.
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
@@ -117,29 +124,29 @@ impl App {
fn setup_refresh_on_startup(&self) { fn setup_refresh_on_startup(&self) {
// Update the feeds right after the Application is initialized. // Update the feeds right after the Application is initialized.
if self.settings.get_boolean("refresh-on-startup") { if self.settings.get_boolean("refresh-on-startup") {
let cleanup_date = utils::get_cleanup_date(&self.settings);
let sender = self.sender.clone(); let sender = self.sender.clone();
info!("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 utils::cleanup(cleanup_date);
// requests that will come from loading the gui on startup.
gtk::timeout_add(1500, move || { gtk::timeout_add_seconds(2, move || {
let s: Option<Vec<_>> = None; utils::refresh(None, sender.clone());
utils::refresh(s, sender.clone());
glib::Continue(false) glib::Continue(false)
}); });
} }
} }
fn setup_auto_refresh(&self) { fn setup_auto_refresh(&self) {
let refresh_interval = settings::get_refresh_interval(&self.settings).num_seconds() as u32; let refresh_interval = utils::get_refresh_interval(&self.settings).num_seconds() as u32;
let sender = self.sender.clone(); let sender = self.sender.clone();
info!("Auto-refresh every {:?} seconds.", refresh_interval); info!("Auto-refresh every {:?} seconds.", refresh_interval);
gtk::timeout_add_seconds(refresh_interval, move || { gtk::timeout_add_seconds(refresh_interval, move || {
let s: Option<Vec<_>> = None; utils::refresh(None, sender.clone());
utils::refresh(s, sender.clone());
glib::Continue(true) glib::Continue(true)
}); });
@@ -149,63 +156,97 @@ impl App {
WindowGeometry::from_settings(&self.settings).apply(&self.window); WindowGeometry::from_settings(&self.settings).apply(&self.window);
let window = self.window.clone(); let window = self.window.clone();
self.app_instance.connect_startup(move |app| { self.app_instance.connect_startup(move |app| {
build_ui(&window, app); build_ui(&window, app);
}); });
self.setup_timed_callbacks(); self.setup_timed_callbacks();
let content = self.content; let content = self.content.clone();
let headerbar = self.header; let headerbar = self.header.clone();
let sender = self.sender; let sender = self.sender.clone();
let overlay = self.overlay; let overlay = self.overlay.clone();
let receiver = self.receiver; let receiver = self.receiver;
gtk::timeout_add(50, move || { gtk::idle_add(move || {
match receiver.try_recv() { match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(Action::UpdateSources(source)) => {
if let Some(s) = source {
utils::refresh(Some(vec![s]), sender.clone());
} else {
utils::refresh(None, sender.clone());
}
}
Ok(Action::RefreshAllViews) => content.update(), Ok(Action::RefreshAllViews) => content.update(),
Ok(Action::RefreshShowsView) => content.update_shows_view(), Ok(Action::RefreshShowsView) => content.update_shows_view(),
Ok(Action::RefreshWidget) => content.update_widget(),
Ok(Action::RefreshWidgetIfVis) => content.update_widget_if_visible(),
Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id), Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id),
Ok(Action::RefreshEpisodesView) => content.update_home(), Ok(Action::RefreshEpisodesView) => content.update_episode_view(),
Ok(Action::RefreshEpisodesViewBGR) => content.update_home_if_background(), Ok(Action::RefreshEpisodesViewBGR) => content.update_episode_view_if_baground(),
Ok(Action::ReplaceWidget(pd)) => { Ok(Action::ReplaceWidget(pd)) => {
let shows = content.get_shows(); if let Err(err) = content.get_shows().replace_widget(pd) {
let mut pop = shows.borrow().populated(); error!("Something went wrong while trying to update the ShowWidget.");
pop.borrow_mut() error!("Error: {}", err);
.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::ShowWidgetAnimated) => content.get_shows().switch_widget_animated(),
Ok(Action::ShowShowsAnimated) => content.get_shows().switch_podcasts_animated(),
Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title), Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(), Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(), Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(), Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
Ok(Action::MarkAllPlayerNotification(pd)) => { Ok(Action::MarkAllPlayerNotification(pd)) => {
let notif = mark_all_notif(pd, &sender); let callback = clone!(sender => move || {
if let Err(err) = mark_all_watched(&pd, sender.clone()) {
error!("Something went horribly wrong with the notif callback: {}", err);
}
glib::Continue(false)
});
let text = "Marked all episodes as listened".into();
let notif = InAppNotification::new(text, callback, || {}, sender.clone());
notif.show(&overlay); notif.show(&overlay);
} }
Ok(Action::RemoveShow(pd)) => { Ok(Action::RemoveShow(pd)) => {
let notif = remove_show_notif(pd, sender.clone()); let text = format!("Unsubscribed from {}", pd.title());
notif.show(&overlay);
} if let Err(err) = utils::ignore_show(pd.id()) {
Ok(Action::ErrorNotification(err)) => { error!("Could not insert {} to the ignore list.", pd.title());
error!("An error notification was triggered: {}", err); error!("Error: {}", err);
let callback = || glib::Continue(false); }
let notif = InAppNotification::new(&err, callback, || {}, UndoState::Hidden);
let callback = clone!(pd => move || {
if let Err(err) = utils::uningore_show(pd.id()) {
error!("Could not remove {} from the ignore list.", pd.title());
error!("Error: {}", err);
}
// Spawn a thread so it won't block the ui.
rayon::spawn(clone!(pd => move || {
if let Err(err) = delete_show(&pd) {
error!("Something went wrong trying to remove {}", pd.title());
error!("Error: {}", err);
}
}));
glib::Continue(false)
});
let sender_ = sender.clone();
let undo_wrap = move || -> Result<(), Error> {
utils::uningore_show(pd.id())?;
sender_.send(Action::RefreshShowsView)?;
sender_.send(Action::RefreshEpisodesView)?;
Ok(())
};
let undo_callback = move || {
if let Err(err) = undo_wrap() {
error!("{}", err)
}
};
let sender_ = sender.clone();
let notif = InAppNotification::new(text, callback, undo_callback, sender_);
notif.show(&overlay); notif.show(&overlay);
} }
Err(_) => (), Err(_) => (),
+13 -12
View File
@@ -2,14 +2,11 @@ use glib;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use app::Action;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone, Copy)]
pub enum UndoState {
Shown,
Hidden,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct InAppNotification { pub struct InAppNotification {
@@ -38,7 +35,12 @@ impl Default for InAppNotification {
} }
impl InAppNotification { impl InAppNotification {
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self pub fn new<F, U>(
text: String,
mut callback: F,
undo_callback: U,
sender: Sender<Action>,
) -> Self
where where
F: FnMut() -> glib::Continue + 'static, F: FnMut() -> glib::Continue + 'static,
U: Fn() + 'static, U: Fn() + 'static,
@@ -65,6 +67,10 @@ impl InAppNotification {
// Hide the notification // Hide the notification
revealer.set_reveal_child(false); revealer.set_reveal_child(false);
// Refresh the widget if visible
if let Err(err) = sender.send(Action::RefreshWidgetIfVis) {
error!("Action channel blew up: {}", err)
}
}); });
// Hide the revealer when the close button is clicked // Hide the revealer when the close button is clicked
@@ -73,11 +79,6 @@ impl InAppNotification {
revealer.set_reveal_child(false); revealer.set_reveal_child(false);
}); });
match show_undo {
UndoState::Shown => (),
UndoState::Hidden => notif.undo.hide(),
}
notif notif
} }
+43 -116
View File
@@ -1,31 +1,27 @@
use glib;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use failure::Error; use failure::Error;
use failure::ResultExt; use failure::ResultExt;
use rayon;
use url::Url; use url::Url;
use hammond_data::{dbqueries, opml, Source}; use hammond_data::Source;
use hammond_data::dbqueries;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use app::Action; use app::Action;
use stacks::Content; use stacks::Content;
use utils::{self, itunes_to_rss, refresh}; use utils::itunes_to_rss;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
// TODO: split this into smaller
pub struct Header { pub struct Header {
container: gtk::HeaderBar, container: gtk::HeaderBar,
add_toggle: gtk::MenuButton, add_toggle: gtk::MenuButton,
switch: gtk::StackSwitcher, switch: gtk::StackSwitcher,
back: gtk::Button, back_button: gtk::Button,
show_title: gtk::Label, show_title: gtk::Label,
about: gtk::ModelButton, about_button: gtk::ModelButton,
import: gtk::ModelButton,
export: gtk::ModelButton,
update_button: gtk::ModelButton, update_button: gtk::ModelButton,
update_box: gtk::Box, update_box: gtk::Box,
update_label: gtk::Label, update_label: gtk::Label,
@@ -36,28 +32,24 @@ impl Default for Header {
fn default() -> Header { fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let header = builder.get_object("headerbar").unwrap(); let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
let add_toggle = builder.get_object("add_toggle").unwrap(); let add_toggle: gtk::MenuButton = builder.get_object("add_toggle").unwrap();
let switch = builder.get_object("switch").unwrap(); let switch: gtk::StackSwitcher = builder.get_object("switch").unwrap();
let back = builder.get_object("back").unwrap(); let back_button: gtk::Button = builder.get_object("back_button").unwrap();
let show_title = builder.get_object("show_title").unwrap(); let show_title: gtk::Label = builder.get_object("show_title").unwrap();
let import = builder.get_object("import").unwrap(); let update_button: gtk::ModelButton = builder.get_object("update_button").unwrap();
let export = builder.get_object("export").unwrap(); let update_box: gtk::Box = builder.get_object("update_notification").unwrap();
let update_button = builder.get_object("update_button").unwrap(); let update_label: gtk::Label = builder.get_object("update_label").unwrap();
let update_box = builder.get_object("update_notification").unwrap(); let update_spinner: gtk::Spinner = builder.get_object("update_spinner").unwrap();
let update_label = builder.get_object("update_label").unwrap(); let about_button: gtk::ModelButton = builder.get_object("about_button").unwrap();
let update_spinner = builder.get_object("update_spinner").unwrap();
let about = builder.get_object("about").unwrap();
Header { Header {
container: header, container: header,
add_toggle, add_toggle,
switch, switch,
back, back_button,
show_title, show_title,
about, about_button,
import,
export,
update_button, update_button,
update_box, update_box,
update_label, update_label,
@@ -68,13 +60,13 @@ impl Default for Header {
// TODO: Refactor components into smaller state machines // TODO: Refactor components into smaller state machines
impl Header { impl Header {
pub fn new(content: &Content, window: &gtk::Window, sender: &Sender<Action>) -> Header { pub fn new(content: &Content, window: &gtk::Window, sender: Sender<Action>) -> Header {
let h = Header::default(); let h = Header::default();
h.init(content, window, &sender); h.init(content, window, sender);
h h
} }
pub fn init(&self, content: &Content, window: &gtk::Window, sender: &Sender<Action>) { pub fn init(&self, content: &Content, window: &gtk::Window, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap(); let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
@@ -84,15 +76,15 @@ impl Header {
self.switch.set_stack(&content.get_stack()); self.switch.set_stack(&content.get_stack());
new_url.connect_changed(clone!(add_button => move |url| { new_url.connect_changed(clone!(add_button => move |url| {
on_url_change(url, &result_label, &add_button) if let Err(err) = on_url_change(url, &result_label, &add_button) {
.map_err(|err| error!("Error: {}", err)) error!("Error: {}", err);
.ok(); }
})); }));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| { add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
on_add_bttn_clicked(&new_url, sender.clone()) if let Err(err) = on_add_bttn_clicked(&new_url, sender.clone()) {
.map_err(|err| error!("Error: {}", err)) error!("Error: {}", err);
.ok(); }
add_popover.hide(); add_popover.hide();
})); }));
@@ -100,19 +92,15 @@ impl Header {
self.update_button self.update_button
.connect_clicked(clone!(sender => move |_| { .connect_clicked(clone!(sender => move |_| {
gtk::idle_add(clone!(sender => move || { sender
let s: Option<Vec<_>> = None; .send(Action::UpdateSources(None))
refresh(s, sender.clone()); .expect("Action channel blew up.");
glib::Continue(false)
}));
})); }));
self.about self.about_button
.connect_clicked(clone!(window => move |_| about_dialog(&window))); .connect_clicked(clone!(window => move |_| {
about_dialog(&window);
self.import.connect_clicked( }));
clone!(window, sender => move |_| on_import_clicked(&window, &sender)),
);
// Add the Headerbar to the window. // Add the Headerbar to the window.
window.set_titlebar(&self.container); window.set_titlebar(&self.container);
@@ -120,15 +108,15 @@ impl Header {
let switch = &self.switch; let switch = &self.switch;
let add_toggle = &self.add_toggle; let add_toggle = &self.add_toggle;
let show_title = &self.show_title; let show_title = &self.show_title;
self.back.connect_clicked( self.back_button.connect_clicked(
clone!(switch, add_toggle, show_title, sender => move |back| { clone!(switch, add_toggle, show_title, sender => move |back| {
switch.show(); switch.show();
add_toggle.show(); add_toggle.show();
back.hide(); back.hide();
show_title.hide(); show_title.hide();
sender.send(Action::ShowShowsAnimated) if let Err(err) = sender.send(Action::ShowShowsAnimated) {
.map_err(|err| error!("Action Sender: {}", err)) error!("Action channel blew up: {}", err);
.ok(); }
}), }),
); );
} }
@@ -136,7 +124,7 @@ impl Header {
pub fn switch_to_back(&self, title: &str) { pub fn switch_to_back(&self, title: &str) {
self.switch.hide(); self.switch.hide();
self.add_toggle.hide(); self.add_toggle.hide();
self.back.show(); self.back_button.show();
self.set_show_title(title); self.set_show_title(title);
self.show_title.show(); self.show_title.show();
} }
@@ -144,7 +132,7 @@ impl Header {
pub fn switch_to_normal(&self) { pub fn switch_to_normal(&self) {
self.switch.show(); self.switch.show();
self.add_toggle.show(); self.add_toggle.show();
self.back.hide(); self.back_button.hide();
self.show_title.hide(); self.show_title.hide();
} }
@@ -182,10 +170,9 @@ fn on_add_bttn_clicked(entry: &gtk::Entry, sender: Sender<Action>) -> Result<(),
let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?; let source = Source::from_url(&url).context("Failed to convert url to a Source entry.")?;
entry.set_text(""); entry.set_text("");
gtk::idle_add(move || { sender
refresh(Some(vec![source.clone()]), sender.clone()); .send(Action::UpdateSources(Some(source)))
glib::Continue(false) .context("App channel blew up.")?;
});
Ok(()) Ok(())
} }
@@ -229,66 +216,6 @@ fn on_url_change(
} }
} }
fn on_import_clicked(window: &gtk::Window, sender: &Sender<Action>) {
use glib::translate::ToGlib;
use gtk::{FileChooserAction, FileChooserDialog, FileFilter, ResponseType};
// let dialog = FileChooserDialog::new(title, Some(&window), FileChooserAction::Open);
// TODO: It might be better to use a FileChooserNative widget.
// Create the FileChooser Dialog
let dialog = FileChooserDialog::with_buttons(
Some("Select the file from which to you want to Import Shows."),
Some(window),
FileChooserAction::Open,
&[
("_Cancel", ResponseType::Cancel),
("_Open", ResponseType::Accept),
],
);
// Do not show hidden(.thing) files
dialog.set_show_hidden(false);
// Set a filter to show only xml files
let filter = FileFilter::new();
FileFilterExt::set_name(&filter, Some("OPML file"));
filter.add_mime_type("application/xml");
filter.add_mime_type("text/xml");
dialog.add_filter(&filter);
dialog.connect_response(clone!(sender => move |dialog, resp| {
debug!("Dialong Response {}", resp);
if resp == ResponseType::Accept.to_glib() {
// TODO: Show an in-app notifictaion if the file can not be accessed
if let Some(filename) = dialog.get_filename() {
debug!("File selected: {:?}", filename);
rayon::spawn(clone!(sender => move || {
// Parse the file and import the feeds
if let Ok(sources) = opml::import_from_file(filename) {
// Refresh the succesfully parsed feeds to index them
utils::refresh(Some(sources), sender)
} else {
let text = String::from("Failed to parse the Imported file");
sender.send(Action::ErrorNotification(text))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
}))
} else {
let text = String::from("Selected File could not be accessed.");
sender.send(Action::ErrorNotification(text))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
}
dialog.destroy();
}));
dialog.run();
}
// 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
fn about_dialog(window: &gtk::Window) { fn about_dialog(window: &gtk::Window) {
@@ -312,7 +239,7 @@ fn about_dialog(window: &gtk::Window) {
dialog.set_modal(true); dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built // TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly // and the version number is kept in sync automaticly
dialog.set_version("0.3.3"); dialog.set_version("0.3.1");
dialog.set_program_name("Hammond"); dialog.set_program_name("Hammond");
// TODO: Need a wiki page first. // TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts"); // dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
+16 -15
View File
@@ -1,10 +1,8 @@
#![cfg_attr( #![cfg_attr(feature = "cargo-clippy",
feature = "cargo-clippy", allow(clone_on_ref_ptr, needless_pass_by_value, useless_format, blacklisted_name,
allow(clone_on_ref_ptr, blacklisted_name, match_same_arms, option_map_unit_fn) match_same_arms))]
)]
#![allow(unknown_lints)] #![allow(unknown_lints)]
#![warn(unused_extern_crates, unused)] #![deny(unused_extern_crates, unused)]
#![deny(warnings)]
extern crate gdk; extern crate gdk;
extern crate gdk_pixbuf; extern crate gdk_pixbuf;
@@ -64,17 +62,20 @@ macro_rules! clone {
); );
} }
mod stacks; // They do not need to be public
mod widgets; // But it helps when looking at the generated docs.
pub mod views;
pub mod widgets;
pub mod stacks;
mod app; pub mod headerbar;
mod headerbar; pub mod app;
mod appnotif; pub mod settings;
mod manager; pub mod utils;
mod settings; pub mod manager;
mod static_resource; pub mod static_resource;
mod utils; pub mod appnotif;
use app::App; use app::App;
+23 -8
View File
@@ -5,8 +5,11 @@ use rayon;
use hammond_data::dbqueries; use hammond_data::dbqueries;
use hammond_downloader::downloader::{get_episode, DownloadProgress}; use hammond_downloader::downloader::{get_episode, DownloadProgress};
use app::Action;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::sync::mpsc::Sender;
// use std::sync::atomic::AtomicUsize; // use std::sync::atomic::AtomicUsize;
// use std::path::PathBuf; // use std::path::PathBuf;
@@ -75,7 +78,7 @@ lazy_static! {
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap(); static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
} }
pub fn add(id: i32, directory: String) -> Result<(), Error> { pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Error> {
// Create a new `Progress` struct to keep track of dl progress. // 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()));
@@ -85,12 +88,14 @@ pub fn add(id: i32, directory: String) -> Result<(), Error> {
}; };
DLPOOL.spawn(move || { DLPOOL.spawn(move || {
if let Ok(mut episode) = dbqueries::get_episode_widget_from_rowid(id) { if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
let pid = episode.podcast_id();
let id = episode.rowid(); let id = episode.rowid();
get_episode(&mut episode, directory.as_str(), Some(prog)) if let Err(err) = get_episode(&mut episode.into(), directory.as_str(), Some(prog)) {
.map_err(|err| error!("Download Failed: {}", err)) error!("Error while trying to download an episode");
.ok(); error!("Error: {}", err);
}
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() { if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
let foo = m.remove(&id); let foo = m.remove(&id);
@@ -100,6 +105,13 @@ pub fn add(id: i32, directory: String) -> Result<(), Error> {
// if let Ok(m) = ACTIVE_DOWNLOADS.read() { // if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m); // debug!("ACTIVE DOWNLOADS: {:#?}", m);
// } // }
sender
.send(Action::RefreshEpisodesView)
.expect("Action channel blew up.");
sender
.send(Action::RefreshWidgetIfSame(pid))
.expect("Action channel blew up.");
} }
}); });
@@ -110,16 +122,17 @@ pub fn add(id: i32, directory: String) -> Result<(), Error> {
mod tests { mod tests {
use super::*; use super::*;
use hammond_data::{Episode, Source};
use hammond_data::dbqueries; use hammond_data::dbqueries;
use hammond_data::pipeline; use hammond_data::pipeline;
use hammond_data::utils::get_download_folder; use hammond_data::utils::get_download_folder;
use hammond_data::{Episode, Source};
use hammond_downloader::downloader::get_episode; use hammond_downloader::downloader::get_episode;
use std::{thread, time};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::{thread, time}; use std::sync::mpsc::channel;
#[test] #[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit // This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
@@ -141,9 +154,11 @@ mod tests {
// Get an episode // Get an episode
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap(); let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
let (sender, _rx) = channel();
let download_fold = get_download_folder(&pd.title()).unwrap(); let download_fold = get_download_folder(&pd.title()).unwrap();
let fold2 = download_fold.clone(); let fold2 = download_fold.clone();
add(episode.rowid(), download_fold).unwrap(); add(episode.rowid(), download_fold, sender).unwrap();
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1); assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
// Give it soem time to download the file // Give it soem time to download the file
+1 -47
View File
@@ -1,11 +1,8 @@
use gio; use gio;
use gio::{Settings, SettingsExt}; use gio::SettingsExt;
use gtk; use gtk;
use gtk::GtkWindowExt; use gtk::GtkWindowExt;
use chrono::prelude::*;
use chrono::Duration;
pub struct WindowGeometry { pub struct WindowGeometry {
left: i32, left: i32,
top: i32, top: i32,
@@ -70,49 +67,6 @@ impl WindowGeometry {
} }
} }
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = i64::from(settings.get_int("refresh-interval-time"));
let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str())
}
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = i64::from(settings.get_int("cleanup-age-time"));
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
}
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period {
"weeks" => Duration::weeks(time),
"days" => Duration::days(time),
"hours" => Duration::hours(time),
"minutes" => Duration::minutes(time),
_ => Duration::seconds(time),
}
}
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test] // #[test]
// fn test_apply_window_geometry() { // fn test_apply_window_geometry() {
// gtk::init().expect("Error initializing gtk."); // gtk::init().expect("Error initializing gtk.");
+46 -40
View File
@@ -4,85 +4,91 @@ use gtk::prelude::*;
use failure::Error; use failure::Error;
use app::Action; use app::Action;
use stacks::{HomeStack, ShowStack}; use stacks::EpisodeStack;
use stacks::ShowStack;
use std::cell::RefCell; use std::sync::Arc;
use std::rc::Rc;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Content { pub struct Content {
stack: gtk::Stack, stack: gtk::Stack,
shows: Rc<RefCell<ShowStack>>, shows: Arc<ShowStack>,
home: Rc<RefCell<HomeStack>>, episodes: Arc<EpisodeStack>,
sender: Sender<Action>, sender: Sender<Action>,
} }
impl Content { impl Content {
pub fn new(sender: Sender<Action>) -> Result<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 episodes = Arc::new(EpisodeStack::new(sender.clone())?);
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())?)); let shows = Arc::new(ShowStack::new(sender.clone())?);
stack.add_titled(&home.borrow().get_stack(), "home", "Recent"); stack.add_titled(&episodes.get_stack(), "episodes", "Episodes");
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows"); stack.add_titled(&shows.get_stack(), "shows", "Shows");
Ok(Content { Ok(Content {
stack, stack,
shows, shows,
home, episodes,
sender, sender,
}) })
} }
pub fn update(&self) { pub fn update(&self) {
self.update_home(); self.update_episode_view();
self.update_shows(); self.update_shows_view();
self.update_widget()
} }
pub fn update_home(&self) { // TODO: Maybe propagate the error?
self.home pub fn update_episode_view(&self) {
.borrow_mut() if let Err(err) = self.episodes.update() {
.update() error!("Something went wrong while trying to update the episode view.");
.map_err(|err| error!("Failed to update HomeView: {}", err)) error!("Error: {}", err);
.ok();
}
pub fn update_home_if_background(&self) {
if self.stack.get_visible_child_name() != Some("home".into()) {
self.update_home();
} }
} }
fn update_shows(&self) { pub fn update_episode_view_if_baground(&self) {
self.shows if self.stack.get_visible_child_name() != Some("episodes".into()) {
.borrow_mut() self.update_episode_view();
.update() }
.map_err(|err| error!("Failed to update ShowsView: {}", err))
.ok();
} }
pub fn update_shows_view(&self) { pub fn update_shows_view(&self) {
self.shows if let Err(err) = self.shows.update_podcasts() {
.borrow_mut() error!("Something went wrong while trying to update the ShowsView.");
.update() error!("Error: {}", err);
.map_err(|err| error!("Failed to update ShowsView: {}", err)) }
.ok(); }
pub fn update_widget(&self) {
if let Err(err) = self.shows.update_widget() {
error!("Something went wrong while trying to update the Show Widget.");
error!("Error: {}", err);
}
} }
pub fn update_widget_if_same(&self, pid: i32) { pub fn update_widget_if_same(&self, pid: i32) {
let pop = self.shows.borrow().populated(); if let Err(err) = self.shows.update_widget_if_same(pid) {
pop.borrow_mut() error!("Something went wrong while trying to update the Show Widget.");
.update_widget_if_same(pid) error!("Error: {}", err);
.map_err(|err| error!("Failed to update ShowsWidget: {}", err)) }
.ok(); }
pub fn update_widget_if_visible(&self) {
if self.stack.get_visible_child_name() == Some("shows".to_string())
&& self.shows.get_stack().get_visible_child_name() == Some("widget".to_string())
{
self.update_widget();
}
} }
pub fn get_stack(&self) -> gtk::Stack { pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone() self.stack.clone()
} }
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> { pub fn get_shows(&self) -> Arc<ShowStack> {
self.shows.clone() self.shows.clone()
} }
} }
+77
View File
@@ -0,0 +1,77 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use failure::Error;
use views::{EmptyView, EpisodesView};
use app::Action;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct EpisodeStack {
stack: gtk::Stack,
sender: Sender<Action>,
}
impl EpisodeStack {
pub fn new(sender: Sender<Action>) -> Result<EpisodeStack, Error> {
let episodes = EpisodesView::new(sender.clone())?;
let empty = EmptyView::new();
let stack = gtk::Stack::new();
stack.add_named(&episodes.container, "episodes");
stack.add_named(&empty.container, "empty");
if episodes.is_empty() {
stack.set_visible_child_name("empty");
} else {
stack.set_visible_child_name("episodes");
}
Ok(EpisodeStack { stack, sender })
}
// Look into refactoring to a state-machine.
pub fn update(&self) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("episodes")
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
let eps = EpisodesView::new(self.sender.clone())?;
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| eps.set_vadjustment(&x));
self.stack.remove(&old);
self.stack.add_named(&eps.container, "episodes");
if eps.is_empty() {
self.stack.set_visible_child_name("empty");
} else {
self.stack.set_visible_child_name("episodes");
}
old.destroy();
Ok(())
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
}
-117
View File
@@ -1,117 +0,0 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
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;
use std::sync::mpsc::Sender;
#[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(())
}
}
+3 -5
View File
@@ -1,9 +1,7 @@
mod content; mod content;
mod home; mod episode;
mod populated;
mod show; mod show;
pub use self::content::Content; pub use self::content::Content;
pub use self::home::HomeStack; pub use self::episode::EpisodeStack;
pub use self::populated::{PopulatedStack, PopulatedState}; pub use self::show::ShowStack;
pub use self::show::{ShowStack, ShowState};
-162
View File
@@ -1,162 +0,0 @@
use gtk;
use gtk::prelude::*;
use gtk::StackTransitionType;
use failure::Error;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use app::Action;
use widgets::{ShowWidget, ShowsView};
use std::rc::Rc;
use std::sync::mpsc::Sender;
use std::sync::Arc;
#[derive(Debug, Clone, Copy)]
pub enum PopulatedState {
View,
Widget,
}
#[derive(Debug, Clone)]
pub struct PopulatedStack {
container: gtk::Box,
populated: Rc<ShowsView>,
show: Rc<ShowWidget>,
stack: gtk::Stack,
state: PopulatedState,
sender: Sender<Action>,
}
impl PopulatedStack {
pub fn new(sender: Sender<Action>) -> Result<PopulatedStack, Error> {
let stack = gtk::Stack::new();
let state = PopulatedState::View;
let populated = ShowsView::new(sender.clone())?;
let show = Rc::new(ShowWidget::default());
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
stack.add_named(&populated.container, "shows");
stack.add_named(&show.container, "widget");
container.add(&stack);
container.show_all();
let show = PopulatedStack {
container,
stack,
populated,
show,
state,
sender,
};
Ok(show)
}
pub fn update(&mut self) {
self.update_widget().map_err(|err| format!("{}", err)).ok();
self.update_shows().map_err(|err| format!("{}", err)).ok();
}
pub fn update_shows(&mut self) -> Result<(), Error> {
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.replace_shows()?;
self.switch_visible(s, StackTransitionType::Crossfade);
Ok(())
}
pub fn replace_shows(&mut self) -> Result<(), Error> {
let old = &self.populated.container.clone();
debug!("Name: {:?}", WidgetExt::get_name(old));
self.populated
.save_alignment()
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
.ok();
let pop = ShowsView::new(self.sender.clone())?;
self.populated = pop;
self.stack.remove(old);
self.stack.add_named(&self.populated.container, "shows");
old.destroy();
Ok(())
}
pub fn replace_widget(&mut self, pd: Arc<Podcast>) -> Result<(), Error> {
let old = self.show.container.clone();
// save the ShowWidget vertical scrollabar alignment
self.show
.podcast_id()
.map(|id| self.show.save_vadjustment(id));
let new = ShowWidget::new(pd, self.sender.clone());
self.show = new;
self.stack.remove(&old);
self.stack.add_named(&self.show.container, "widget");
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::None);
Ok(())
}
pub fn update_widget(&mut self) -> Result<(), Error> {
let old = self.show.container.clone();
let id = self.show.podcast_id();
if id.is_none() {
return Ok(());
}
let pd = dbqueries::get_podcast_from_id(id.unwrap_or_default())?;
self.replace_widget(Arc::new(pd))?;
// The current visible child might change depending on
// removal and insertion in the gtk::Stack, so we have
// to make sure it will stay the same.
let s = self.state;
self.switch_visible(s, StackTransitionType::Crossfade);
old.destroy();
Ok(())
}
// Only update widget if its podcast_id is equal to pid.
pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
if self.show.podcast_id() != Some(pid) {
debug!("Different widget. Early return");
return Ok(());
}
self.update_widget()
}
pub fn container(&self) -> gtk::Box {
self.container.clone()
}
pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
use self::PopulatedState::*;
match state {
View => {
self.stack.set_visible_child_full("shows", animation);
self.state = View;
}
Widget => {
self.stack.set_visible_child_full("widget", animation);
self.state = Widget;
}
}
}
}
+153 -66
View File
@@ -1,94 +1,181 @@
use gtk; use gtk;
use gtk::Cast;
use gtk::prelude::*; use gtk::prelude::*;
use failure::Error; use failure::Error;
use hammond_data::dbqueries::is_podcasts_populated;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use views::{EmptyView, ShowsPopulated};
use app::Action; use app::Action;
use stacks::PopulatedStack; use widgets::ShowWidget;
use utils::get_ignored_shows;
use widgets::EmptyView;
use std::cell::RefCell; use std::sync::Arc;
use std::rc::Rc;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
#[derive(Debug, Clone, Copy)]
pub enum ShowState {
Populated,
Empty,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ShowStack { pub struct ShowStack {
empty: EmptyView,
populated: Rc<RefCell<PopulatedStack>>,
stack: gtk::Stack, stack: gtk::Stack,
state: ShowState,
sender: Sender<Action>, sender: Sender<Action>,
} }
impl ShowStack { impl ShowStack {
pub fn new(sender: Sender<Action>) -> Result<Self, Error> { pub fn new(sender: Sender<Action>) -> Result<ShowStack, Error> {
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())?));
let empty = EmptyView::new();
let stack = gtk::Stack::new(); let stack = gtk::Stack::new();
let state = ShowState::Empty;
stack.add_named(&populated.borrow().container(), "populated"); let show = ShowStack {
stack.add_named(&empty.container, "empty");
let mut show = ShowStack {
empty,
populated,
stack, stack,
state, sender: sender.clone(),
sender,
}; };
show.determine_state()?; let pop = ShowsPopulated::new(sender.clone())?;
let widget = ShowWidget::default();
let empty = EmptyView::new();
show.stack.add_named(&pop.container, "podcasts");
show.stack.add_named(&widget.container, "widget");
show.stack.add_named(&empty.container, "empty");
if pop.is_empty() {
show.stack.set_visible_child_name("empty")
} else {
show.stack.set_visible_child_name("podcasts")
}
Ok(show) Ok(show)
} }
// pub fn update(&self) {
// self.update_widget();
// self.update_podcasts();
// }
pub fn update_podcasts(&self) -> Result<(), Error> {
let vis = self.stack
.get_visible_child_name()
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
let old = self.stack
.get_child_by_name("podcasts")
.ok_or_else(|| format_err!("Faild to get \"podcasts\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
let pop = ShowsPopulated::new(self.sender.clone())?;
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| pop.set_vadjustment(&x));
self.stack.remove(&old);
self.stack.add_named(&pop.container, "podcasts");
if pop.is_empty() {
self.stack.set_visible_child_name("empty");
} else if vis != "empty" {
self.stack.set_visible_child_name(&vis);
} else {
self.stack.set_visible_child_name("podcasts");
}
old.destroy();
Ok(())
}
pub fn replace_widget(&self, pd: Arc<Podcast>) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?
.downcast::<gtk::Box>()
.map_err(|_| format_err!("Failed to downcast stack child to a Box."))?;
debug!("Name: {:?}", WidgetExt::get_name(&old));
let new = ShowWidget::new(pd, self.sender.clone());
// Each composite ShowWidget is a gtkBox with the Podcast.id encoded in the
// gtk::Widget name. It's a hack since we can't yet subclass GObject
// easily.
let oldid = WidgetExt::get_name(&old);
let newid = WidgetExt::get_name(&new.container);
debug!("Old widget Name: {:?}\nNew widget Name: {:?}", oldid, newid);
// Only copy the old scrollbar if both widget's represent the same podcast.
if newid == oldid {
let scrolled_window = old.get_children()
.first()
.ok_or_else(|| format_err!("Box container has no childs."))?
.clone()
.downcast::<gtk::ScrolledWindow>()
.map_err(|_| format_err!("Failed to downcast stack child to a ScrolledWindow."))?;
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
// Copy the vertical scrollbar adjustment from the old view into the new one.
scrolled_window
.get_vadjustment()
.map(|x| new.set_vadjustment(&x));
}
self.stack.remove(&old);
self.stack.add_named(&new.container, "widget");
Ok(())
}
pub fn update_widget(&self) -> Result<(), Error> {
let vis = self.stack
.get_visible_child_name()
.ok_or_else(|| format_err!("Failed to get visible child name."))?;
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
let id = WidgetExt::get_name(&old);
if id == Some("GtkBox".to_string()) || id.is_none() {
return Ok(());
}
let id = id.ok_or_else(|| format_err!("Failed to get widget's name."))?;
let pd = dbqueries::get_podcast_from_id(id.parse::<i32>()?)?;
self.replace_widget(Arc::new(pd))?;
self.stack.set_visible_child_name(&vis);
old.destroy();
Ok(())
}
// Only update widget if it's podcast_id is equal to pid.
pub fn update_widget_if_same(&self, pid: i32) -> Result<(), Error> {
let old = self.stack
.get_child_by_name("widget")
.ok_or_else(|| format_err!("Faild to get \"widget\" child from the stack."))?;
let id = WidgetExt::get_name(&old);
if id != Some(pid.to_string()) || id.is_none() {
debug!("Different widget. Early return");
return Ok(());
}
self.update_widget()
}
pub fn switch_podcasts_animated(&self) {
self.stack
.set_visible_child_full("podcasts", gtk::StackTransitionType::SlideRight);
}
pub fn switch_widget_animated(&self) {
self.stack
.set_visible_child_full("widget", gtk::StackTransitionType::SlideLeft)
}
pub fn get_stack(&self) -> gtk::Stack { pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone() self.stack.clone()
} }
pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
self.populated.clone()
}
pub fn update(&mut self) -> Result<(), Error> {
self.populated.borrow_mut().update();
self.determine_state()
}
fn switch_visible(&mut self, s: ShowState) {
use self::ShowState::*;
match s {
Populated => {
self.stack.set_visible_child_name("populated");
self.state = Populated;
}
Empty => {
self.stack.set_visible_child_name("empty");
self.state = Empty;
}
};
}
fn determine_state(&mut self) -> Result<(), Error> {
use self::ShowState::*;
let ign = get_ignored_shows()?;
debug!("IGNORED SHOWS {:?}", ign);
if is_podcasts_populated(&ign)? {
self.switch_visible(Populated);
} else {
self.switch_visible(Empty);
};
Ok(())
}
} }
+110 -168
View File
@@ -1,13 +1,11 @@
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))] #![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use gdk::FrameClockExt;
use gdk_pixbuf::Pixbuf; use gdk_pixbuf::Pixbuf;
use gio::{Settings, SettingsExt};
use glib; use glib;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{IsA, Widget};
use chrono::prelude::*;
use failure::Error; use failure::Error;
use rayon; use rayon;
use regex::Regex; use regex::Regex;
@@ -16,130 +14,21 @@ use send_cell::SendCell;
use serde_json::Value; use serde_json::Value;
// use hammond_data::feed; // use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries; use hammond_data::dbqueries;
use hammond_data::pipeline; use hammond_data::pipeline;
use hammond_data::utils::checkup; use hammond_data::utils::checkup;
use hammond_data::Source;
use hammond_downloader::downloader; use hammond_downloader::downloader;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::mpsc::*;
use std::sync::Arc;
use std::sync::{Mutex, RwLock}; use std::sync::{Mutex, RwLock};
use std::sync::Arc;
use std::sync::mpsc::*;
use app::Action; use app::Action;
/// Lazy evaluates and loads widgets to the parent `container` widget. use chrono::Duration;
/// use chrono::prelude::*;
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
/// will be constructed. An `FnMut` function that returns the desired
/// widget should be passed as the widget `constructor`. You can also specify
/// a `callback` that will be executed when the iteration finish.
///
/// ```no_run
/// # struct Message;
/// # struct MessageWidget(gtk::Label);
///
/// # impl MessageWidget {
/// # fn new(_: Message) -> Self {
/// # MessageWidget(gtk::Label::new("A message"))
/// # }
/// # }
///
/// let messages: Vec<Message> = Vec::new();
/// let list = gtk::ListBox::new();
/// let constructor = |m| { MessageWidget::new(m).0};
/// lazy_load(messages, list, constructor, || {});
/// ```
///
/// If you have already constructed the widgets and only want to
/// load them to the parent you can pass a closure that returns it's
/// own argument to the constructor.
///
/// ```no_run
/// # use std::collections::binary_heap::BinaryHeap;
/// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
/// let list = gtk::ListBox::new();
/// lazy_load(widgets, list, |w| w, || {});
/// ```
pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
C: ContainerExt + 'static,
F: FnMut(T::Item) -> W + 'static,
W: IsA<Widget> + WidgetExt,
U: Fn() + 'static,
{
let func = move |x| {
let widget = contructor(x);
container.add(&widget);
widget.show();
};
lazy_load_full(data, func, callback);
}
/// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
/// when the iteration finishes, it executes `finish_callback`.
///
/// This is a more flexible version of `lazy_load` with less constrains.
/// If you just want to lazy add `widgets` to a `container` check if
/// `lazy_load` fits your needs first.
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
where
T: IntoIterator + 'static,
T::Item: 'static,
F: FnMut(T::Item) + 'static,
U: Fn() + 'static,
{
let mut data = data.into_iter();
gtk::idle_add(move || {
data.next()
.map(|x| func(x))
.map(|_| glib::Continue(true))
.unwrap_or_else(|| {
finish_callback();
glib::Continue(false)
})
});
}
// Kudos to Julian Sparber
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
pub fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::Adjustment) {
if let Some(adj) = view.get_vadjustment() {
if let Some(clock) = view.get_frame_clock() {
let duration = 200;
let start = adj.get_value();
let end = target.get_value();
let start_time = clock.get_frame_time();
let end_time = start_time + 1000 * duration;
view.add_tick_callback(move |_, clock| {
let now = clock.get_frame_time();
// FIXME: `adj.get_value != end` is a float comparison...
if now < end_time && adj.get_value().abs() != end.abs() {
let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
t = ease_out_cubic(t);
adj.set_value(start + t * (end - start));
glib::Continue(true)
} else {
adj.set_value(end);
glib::Continue(false)
}
});
}
}
}
// From clutter-easing.c, based on Robert Penner's
// infamous easing equations, MIT license.
fn ease_out_cubic(t: f64) -> f64 {
let p = t - 1f64;
p * p * p + 1f64
}
lazy_static! { 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()));
@@ -167,56 +56,78 @@ pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
} }
pub fn cleanup(cleanup_date: DateTime<Utc>) { pub fn cleanup(cleanup_date: DateTime<Utc>) {
checkup(cleanup_date) if let Err(err) = checkup(cleanup_date) {
.map_err(|err| error!("Check up failed: {}", err)) error!("Check up failed: {}", err);
.ok(); }
} }
pub fn refresh<S>(source: Option<S>, sender: Sender<Action>) pub fn refresh(source: Option<Vec<Source>>, sender: Sender<Action>) {
where if let Err(err) = refresh_feed(source, sender) {
S: IntoIterator<Item = Source> + Send + 'static, error!("An error occured while trying to update the feeds.");
{ error!("Error: {}", err);
refresh_feed(source, sender) }
.map_err(|err| error!("Failed to update feeds: {}", err)) }
.ok();
pub fn get_refresh_interval(settings: &Settings) -> Duration {
let time = settings.get_int("refresh-interval-time") as i64;
let period = settings.get_string("refresh-interval-period").unwrap();
time_period_to_duration(time, period.as_str())
}
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
let time = settings.get_int("cleanup-age-time") as i64;
let period = settings.get_string("cleanup-age-period").unwrap();
let duration = time_period_to_duration(time, period.as_str());
Utc::now() - duration
} }
/// Update the rss feed(s) originating from `source`. /// 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.
/// When It's done,it queues up a `RefreshViews` action. /// When It's done,it queues up a `RefreshViews` action.
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error> fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
where sender.send(Action::HeaderBarShowUpdateIndicator)?;
S: IntoIterator<Item = Source> + Send + 'static,
{
sender
.send(Action::HeaderBarShowUpdateIndicator)
.map_err(|err| error!("Action Sender: {}", err))
.ok();
rayon::spawn(move || { rayon::spawn(move || {
if let Some(s) = source { let mut sources = source.unwrap_or_else(|| {
// Refresh only specified feeds dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
pipeline::run(s, false) });
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Error While trying to update the database.")) // Work around to improve the feed addition experience.
.ok(); // Many times links to rss feeds are just redirects(usually to an https
} else { // version). Sadly I haven't figured yet a nice way to follow up links
// Refresh all the feeds // redirects without getting to lifetime hell with futures and hyper.
dbqueries::get_sources() // So the requested refresh is only of 1 feed, and the feed fails to be indexed,
.map(|s| s.into_iter()) // (as a 301 redict would update the source entry and exit), another refresh is
.and_then(|s| pipeline::run(s, false)) // run. For more see hammond_data/src/models/source.rs `fn
.map_err(|err| error!("Error: {}", err)) // request_constructor`. also ping me on irc if or open an issue if you
.ok(); // want to tackle it.
}; if sources.len() == 1 {
let source = sources.remove(0);
let id = source.id();
if let Err(err) = pipeline::index_single_source(source, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
if let Ok(source) = dbqueries::get_source_from_id(id) {
if let Err(err) = pipeline::index_single_source(source, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
}
}
// This is what would normally run
} else if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
sender sender
.send(Action::HeaderBarHideUpdateIndicator) .send(Action::HeaderBarHideUpdateIndicator)
.map_err(|err| error!("Action Sender: {}", err)) .expect("Action channel blew up.");
.ok();
sender sender
.send(Action::RefreshAllViews) .send(Action::RefreshAllViews)
.map_err(|err| error!("Action Sender: {}", err)) .expect("Action channel blew up.");
.ok();
}); });
Ok(()) Ok(())
} }
@@ -235,15 +146,19 @@ lazy_static! {
// 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 fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Result<(), Error> { pub fn set_image_from_path(
image: &gtk::Image,
pd: Arc<PodcastCoverQuery>,
size: u32,
) -> Result<(), Error> {
// Check if there's an active download about this show cover. // 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 succedes, 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(&podcast_id) { if guard.contains(&pd.id()) {
let callback = clone!(image => move || { let callback = clone!(image, pd => move || {
let _ = set_image_from_path(&image, podcast_id, size); let _ = set_image_from_path(&image, pd.clone(), size);
glib::Continue(false) glib::Continue(false)
}); });
gtk::timeout_add(250, callback); gtk::timeout_add(250, callback);
@@ -254,7 +169,7 @@ pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Re
if let Ok(hashmap) = CACHED_PIXBUFS.read() { if let Ok(hashmap) = CACHED_PIXBUFS.read() {
// Check if the requested (cover + size) is already in the chache // Check if the requested (cover + size) is already in the chache
// and if so do an early return after that. // and if so do an early return after that.
if let Some(guard) = hashmap.get(&(podcast_id, size)) { if let Some(guard) = hashmap.get(&(pd.id(), size)) {
guard guard
.lock() .lock()
.map_err(|err| format_err!("SendCell Mutex: {}", err)) .map_err(|err| format_err!("SendCell Mutex: {}", err))
@@ -270,20 +185,16 @@ pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Re
} }
let (sender, receiver) = channel(); let (sender, receiver) = channel();
let pd_ = pd.clone();
THREADPOOL.spawn(move || { THREADPOOL.spawn(move || {
if let Ok(mut guard) = COVER_DL_REGISTRY.write() { if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.insert(podcast_id); guard.insert(pd_.id());
} }
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(podcast_id) { let _ = sender.send(downloader::cache_image(&pd_));
sender
.send(downloader::cache_image(&pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
}
if let Ok(mut guard) = COVER_DL_REGISTRY.write() { if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
guard.remove(&podcast_id); guard.remove(&pd_.id());
} }
}); });
@@ -294,7 +205,7 @@ pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Re
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((podcast_id, size), Mutex::new(SendCell::new(px.clone()))); hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
image.set_from_pixbuf(&px); image.set_from_pixbuf(&px);
} }
} }
@@ -307,12 +218,14 @@ pub fn set_image_from_path(image: &gtk::Image, podcast_id: i32, size: u32) -> Re
Ok(()) Ok(())
} }
#[inline]
// 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 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)
} }
#[inline]
fn itunes_id_from_url(url: &str) -> Option<u32> { fn itunes_id_from_url(url: &str) -> Option<u32> {
lazy_static! { lazy_static! {
static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap(); static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
@@ -324,6 +237,7 @@ fn itunes_id_from_url(url: &str) -> Option<u32> {
foo.parse::<u32>().ok() foo.parse::<u32>().ok()
} }
#[inline]
fn lookup_id(id: u32) -> Result<String, Error> { fn lookup_id(id: u32) -> Result<String, Error> {
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id); let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
let req: Value = reqwest::get(&url)?.json()?; let req: Value = reqwest::get(&url)?.json()?;
@@ -333,17 +247,45 @@ 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 fn time_period_to_duration(time: i64, period: &str) -> Duration {
match period {
"weeks" => Duration::weeks(time),
"days" => Duration::days(time),
"hours" => Duration::hours(time),
"minutes" => Duration::minutes(time),
_ => Duration::seconds(time),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// use hammond_data::Source; // use hammond_data::Source;
// use hammond_data::dbqueries; // use hammond_data::dbqueries;
#[test]
fn test_time_period_to_duration() {
let time = 2;
let week = 604800 * time;
let day = 86400 * time;
let hour = 3600 * time;
let minute = 60 * time;
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
assert_eq!(
minute,
time_period_to_duration(time, "minutes").num_seconds()
);
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
}
// #[test] // #[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.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/hammond/issues/56 // Disabled till https://gitlab.gnome.org/alatiera/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
@@ -1,25 +1,17 @@
use chrono::prelude::*; use chrono::prelude::*;
use failure::Error; use failure::Error;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use hammond_data::dbqueries;
use hammond_data::EpisodeWidgetQuery; use hammond_data::EpisodeWidgetQuery;
use send_cell::SendCell; use hammond_data::dbqueries;
use app::Action; use app::Action;
use utils::{self, lazy_load_full}; use utils::{get_ignored_shows, set_image_from_path};
use widgets::EpisodeWidget; use widgets::EpisodeWidget;
use std::rc::Rc; use std::sync::Arc;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
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 {
@@ -31,7 +23,7 @@ enum ListSplit {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HomeView { pub struct EpisodesView {
pub container: gtk::Box, pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow, scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box, frame_parent: gtk::Box,
@@ -47,7 +39,7 @@ pub struct HomeView {
rest_list: gtk::ListBox, rest_list: gtk::ListBox,
} }
impl Default for HomeView { impl Default for EpisodesView {
fn default() -> Self { fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
let container: gtk::Box = builder.get_object("container").unwrap(); let container: gtk::Box = builder.get_object("container").unwrap();
@@ -64,7 +56,7 @@ 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();
HomeView { EpisodesView {
container, container,
scrolled_window, scrolled_window,
frame_parent, frame_parent,
@@ -83,75 +75,89 @@ impl Default for HomeView {
} }
// TODO: REFACTOR ME // TODO: REFACTOR ME
impl HomeView { impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> { pub fn new(sender: Sender<Action>) -> Result<EpisodesView, Error> {
use self::ListSplit::*; let view = EpisodesView::default();
let ignore = get_ignored_shows()?;
let view = Rc::new(HomeView::default()); let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 50)?;
let ignore = utils::get_ignored_shows()?;
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
let now_utc = Utc::now(); let now_utc = Utc::now();
let view_ = view.clone(); episodes.into_iter().for_each(|ep| {
let func = move |ep: EpisodeWidgetQuery| {
let epoch = ep.epoch(); let epoch = ep.epoch();
let widget = HomeEpisode::new(ep, &sender); let viewep = EpisodesViewWidget::new(ep, sender.clone());
match split(&now_utc, i64::from(epoch)) { let t = split(&now_utc, i64::from(epoch));
Today => add_to_box(&widget, &view_.today_list, &view_.today_box), match t {
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box), ListSplit::Today => {
Week => add_to_box(&widget, &view_.week_list, &view_.week_box), view.today_list.add(&viewep.container);
Month => add_to_box(&widget, &view_.month_list, &view_.month_box), }
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box), ListSplit::Yday => {
view.yday_list.add(&viewep.container);
}
ListSplit::Week => {
view.week_list.add(&viewep.container);
}
ListSplit::Month => {
view.month_list.add(&viewep.container);
}
ListSplit::Rest => {
view.rest_list.add(&viewep.container);
}
} }
}; });
let view_ = view.clone(); if view.today_list.get_children().is_empty() {
let callback = move || { view.today_box.hide();
view_ }
.set_vadjustment()
.map_err(|err| format!("{}", err)) if view.yday_list.get_children().is_empty() {
.ok(); view.yday_box.hide();
}; }
if view.week_list.get_children().is_empty() {
view.week_box.hide();
}
if view.month_list.get_children().is_empty() {
view.month_box.hide();
}
if view.rest_list.get_children().is_empty() {
view.rest_box.hide();
}
lazy_load_full(episodes, func, callback);
view.container.show_all(); view.container.show_all();
Ok(view) Ok(view)
} }
pub fn is_empty(&self) -> bool {
if !self.today_list.get_children().is_empty() {
return false;
}
if !self.yday_list.get_children().is_empty() {
return false;
}
if !self.week_list.get_children().is_empty() {
return false;
}
if !self.month_list.get_children().is_empty() {
return false;
}
if !self.rest_list.get_children().is_empty() {
return false;
}
true
}
/// Set scrolled window vertical adjustment. /// Set scrolled window vertical adjustment.
fn set_vadjustment(&self) -> Result<(), Error> { pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
let guard = EPISODES_VIEW_VALIGNMENT self.scrolled_window.set_vadjustment(vadjustment)
.lock()
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
if let Some(ref sendcell) = *guard {
// Copy the vertical scrollbar adjustment from the old view into the new one.
sendcell
.try_get()
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
}
Ok(())
} }
/// Save the vertical scrollbar position.
pub fn save_alignment(&self) -> Result<(), Error> {
if let Ok(mut guard) = EPISODES_VIEW_VALIGNMENT.lock() {
let adj = self.scrolled_window
.get_vadjustment()
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
*guard = Some(SendCell::new(adj));
info!("Saved episodes_view alignment.");
}
Ok(())
}
}
fn add_to_box(widget: &HomeEpisode, listbox: &gtk::ListBox, box_: &gtk::Box) {
listbox.add(&widget.container);
box_.show();
} }
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit { fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
@@ -171,13 +177,13 @@ fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct HomeEpisode { struct EpisodesViewWidget {
container: gtk::Box, container: gtk::Box,
image: gtk::Image, image: gtk::Image,
episode: gtk::Box, episode: gtk::Box,
} }
impl Default for HomeEpisode { impl Default for EpisodesViewWidget {
fn default() -> Self { fn default() -> Self {
let builder = let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui"); gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
@@ -186,7 +192,7 @@ impl Default for HomeEpisode {
let ep = EpisodeWidget::default(); let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 6); container.pack_start(&ep.container, true, true, 6);
HomeEpisode { EpisodesViewWidget {
container, container,
image, image,
episode: ep.container, episode: ep.container,
@@ -194,16 +200,16 @@ impl Default for HomeEpisode {
} }
} }
impl HomeEpisode { impl EpisodesViewWidget {
fn new(episode: EpisodeWidgetQuery, sender: &Sender<Action>) -> HomeEpisode { fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
let builder = let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui"); gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap(); let 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.podcast_id(); let pid = episode.podcast_id();
let ep = EpisodeWidget::new(episode, sender); let ep = EpisodeWidget::new(episode, sender.clone());
let view = HomeEpisode { let view = EpisodesViewWidget {
container, container,
image, image,
episode: ep.container, episode: ep.container,
@@ -214,14 +220,15 @@ impl HomeEpisode {
} }
fn init(&self, podcast_id: i32) { fn init(&self, podcast_id: i32) {
self.set_cover(podcast_id) if let Err(err) = self.set_cover(podcast_id) {
.map_err(|err| error!("Failed to set a cover: {}", err)) error!("Failed to set a cover: {}", err)
.ok(); }
self.container.pack_start(&self.episode, true, true, 6); self.container.pack_start(&self.episode, true, true, 6);
} }
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> { fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
utils::set_image_from_path(&self.image, podcast_id, 64) let pd = Arc::new(dbqueries::get_podcast_cover_from_id(podcast_id)?);
set_image_from_path(&self.image, pd, 64)
} }
} }
+7
View File
@@ -0,0 +1,7 @@
mod shows;
mod episodes;
mod empty;
pub use self::empty::EmptyView;
pub use self::episodes::EpisodesView;
pub use self::shows::ShowsPopulated;
+138
View File
@@ -0,0 +1,138 @@
use failure::Error;
use gtk;
use gtk::prelude::*;
use hammond_data::{Podcast, PodcastCoverQuery};
use hammond_data::dbqueries;
use app::Action;
use utils::{get_ignored_shows, set_image_from_path};
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct ShowsPopulated {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
flowbox: gtk::FlowBox,
}
impl Default for ShowsPopulated {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
ShowsPopulated {
container,
scrolled_window,
flowbox,
}
}
}
impl ShowsPopulated {
pub fn new(sender: Sender<Action>) -> Result<ShowsPopulated, Error> {
let pop = ShowsPopulated::default();
pop.init(sender)?;
Ok(pop)
}
pub fn init(&self, sender: Sender<Action>) -> Result<(), Error> {
self.flowbox.connect_child_activated(move |_, child| {
if let Err(err) = on_child_activate(child, sender.clone()) {
error!(
"Something went wrong during flowbox child activation: {}.",
err
)
};
});
// Populate the flowbox with the Podcasts.
self.populate_flowbox()
}
fn populate_flowbox(&self) -> Result<(), Error> {
let ignore = get_ignored_shows()?;
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
podcasts.into_iter().for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
Ok(())
}
pub fn is_empty(&self) -> bool {
self.flowbox.get_children().is_empty()
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn on_child_activate(child: &gtk::FlowBoxChild, sender: Sender<Action>) -> Result<(), Error> {
use gtk::WidgetExt;
// This is such an ugly hack...
let id = WidgetExt::get_name(child)
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
.parse::<i32>()?;
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
sender.send(Action::HeaderBarShowTile(pd.title().into()))?;
sender.send(Action::ReplaceWidget(pd))?;
sender.send(Action::ShowWidgetAnimated)?;
Ok(())
}
#[derive(Debug)]
struct ShowsChild {
container: gtk::Box,
cover: gtk::Image,
child: gtk::FlowBoxChild,
}
impl Default for ShowsChild {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/shows_child.ui");
let container: gtk::Box = builder.get_object("fb_child").unwrap();
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
let child = gtk::FlowBoxChild::new();
child.add(&container);
ShowsChild {
container,
cover,
child,
}
}
}
impl ShowsChild {
pub fn new(pd: Podcast) -> ShowsChild {
let child = ShowsChild::default();
child.init(pd);
child
}
fn init(&self, pd: Podcast) {
self.container.set_tooltip_text(pd.title());
WidgetExt::set_name(&self.child, &pd.id().to_string());
let pd = Arc::new(pd.into());
if let Err(err) = self.set_cover(pd) {
error!("Failed to set a cover: {}", err)
}
}
fn set_cover(&self, pd: Arc<PodcastCoverQuery>) -> Result<(), Error> {
set_image_from_path(&self.cover, pd, 256)
}
}
+150 -121
View File
@@ -5,11 +5,12 @@ use gtk::prelude::*;
use failure::Error; use failure::Error;
use humansize::FileSize; use humansize::FileSize;
use open; use open;
use rayon;
use take_mut; use take_mut;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries; use hammond_data::dbqueries;
use hammond_data::utils::get_download_folder; use hammond_data::utils::get_download_folder;
use hammond_data::EpisodeWidgetQuery;
use app::Action; use app::Action;
use manager; use manager;
@@ -19,8 +20,8 @@ use std::cell::RefCell;
use std::ops::DerefMut; use std::ops::DerefMut;
use std::path::Path; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::sync::mpsc::Sender;
#[derive(Debug)] #[derive(Debug)]
pub struct EpisodeWidget { pub struct EpisodeWidget {
@@ -28,7 +29,7 @@ pub struct EpisodeWidget {
date: DateMachine, date: DateMachine,
duration: DurationMachine, duration: DurationMachine,
title: Rc<RefCell<TitleMachine>>, title: Rc<RefCell<TitleMachine>>,
media: Rc<RefCell<MediaMachine>>, media: Arc<Mutex<MediaMachine>>,
} }
impl Default for EpisodeWidget { impl Default for EpisodeWidget {
@@ -65,7 +66,7 @@ impl Default for EpisodeWidget {
separator2, separator2,
prog_separator, prog_separator,
); );
let media_machine = Rc::new(RefCell::new(media)); let media_machine = Arc::new(Mutex::new(media));
EpisodeWidget { EpisodeWidget {
container, container,
@@ -78,13 +79,15 @@ impl Default for EpisodeWidget {
} }
impl EpisodeWidget { impl EpisodeWidget {
pub fn new(episode: EpisodeWidgetQuery, sender: &Sender<Action>) -> EpisodeWidget { pub fn new(episode: EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let mut widget = EpisodeWidget::default(); let mut widget = EpisodeWidget::default();
widget.init(episode, sender); widget.init(episode, sender);
widget widget
} }
fn init(&mut self, episode: EpisodeWidgetQuery, sender: &Sender<Action>) { fn init(&mut self, episode: EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
// Set the date label. // Set the date label.
self.set_date(episode.epoch()); self.set_date(episode.epoch());
@@ -95,43 +98,36 @@ impl EpisodeWidget {
self.set_duration(episode.duration()); self.set_duration(episode.duration());
// Determine what the state of the media widgets should be. // Determine what the state of the media widgets should be.
determine_media_state(&self.media, &episode) if let Err(err) = self.determine_media_state(&episode) {
.map_err(|err| error!("Error: {}", err)) error!("Something went wrong determining the Media State.");
.map_err(|_| error!("Could not determine Media State")) error!("Error: {}", err);
.ok(); }
let episode = Arc::new(Mutex::new(episode)); let episode = Arc::new(Mutex::new(episode));
self.connect_buttons(&episode, sender); self.connect_buttons(episode, sender);
} }
fn connect_buttons(&self, episode: &Arc<Mutex<EpisodeWidgetQuery>>, sender: &Sender<Action>) { fn connect_buttons(&self, episode: Arc<Mutex<EpisodeWidgetQuery>>, sender: Sender<Action>) {
let title = self.title.clone(); let title = self.title.clone();
if let Ok(media) = self.media.try_borrow_mut() { if let Ok(media) = self.media.lock() {
media.play_connect_clicked(clone!(episode, sender => move |_| { media.play_connect_clicked(clone!(episode, sender => move |_| {
if let Ok(mut ep) = episode.lock() { if let Ok(mut ep) = episode.lock() {
on_play_bttn_clicked(&mut ep, &title, &sender) if let Err(err) = on_play_bttn_clicked(&mut ep, title.clone(), sender.clone()){
.map_err(|err| error!("Error: {}", err)) error!("Error: {}", err);
.ok(); };
} }
})); }));
let media_machine = self.media.clone(); media.download_connect_clicked(clone!(episode, sender => move |dl| {
media.download_connect_clicked(clone!(media_machine, episode, sender => move |dl| {
// Make the button insensitive so it won't be pressed twice
dl.set_sensitive(false); dl.set_sensitive(false);
if let Ok(ep) = episode.lock() { if let Ok(ep) = episode.lock() {
on_download_clicked(&ep, &sender) if let Err(err) = on_download_clicked(&ep, sender.clone()) {
.and_then(|_| { error!("Download failed to start.");
info!("Donwload started succesfully."); error!("Error: {}", err);
determine_media_state(&media_machine, &ep) } else {
}) info!("Donwload started succesfully.");
.map_err(|err| error!("Error: {}", err)) }
.map_err(|_| error!("Could not determine Media State"))
.ok();
} }
// Restore sensitivity after operations above complete
dl.set_sensitive(true);
})); }));
} }
} }
@@ -156,100 +152,67 @@ impl EpisodeWidget {
let machine = &mut self.duration; let machine = &mut self.duration;
take_mut::take(machine, |duration| duration.determine_state(seconds)); take_mut::take(machine, |duration| duration.determine_state(seconds));
} }
}
fn determine_media_state( fn determine_media_state(&self, episode: &EpisodeWidgetQuery) -> Result<(), Error> {
media_machine: &Rc<RefCell<MediaMachine>>, let id = WidgetExt::get_name(&self.container)
episode: &EpisodeWidgetQuery, .ok_or_else(|| format_err!("Failed to get widget Name"))?
) -> Result<(), Error> { .parse::<i32>()?;
let id = episode.rowid();
let active_dl = || -> Result<Option<_>, Error> {
let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
Ok(m.get(&id).cloned()) let active_dl = || -> Result<Option<_>, Error> {
}()?; let m = manager::ACTIVE_DOWNLOADS
.read()
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
let mut lock = media_machine.try_borrow_mut()?; Ok(m.get(&id).cloned())
take_mut::take(lock.deref_mut(), |media| { }()?;
media.determine_state(
episode.length(),
active_dl.is_some(),
episode.local_uri().is_some(),
)
});
// Show or hide the play/delete/download buttons upon widget initialization. let mut lock = self.media.lock().map_err(|err| format_err!("{}", err))?;
if let Some(prog) = active_dl { take_mut::take(lock.deref_mut(), |media| {
// set a callback that will update the state when the download finishes media.determine_state(
let id = episode.rowid(); episode.length(),
let callback = clone!(media_machine => move || { active_dl.is_some(),
if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() { episode.local_uri().is_some(),
if !guard.contains_key(&id) { )
if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) {
determine_media_state(&media_machine, &ep)
.map_err(|err| error!("Error: {}", err))
.map_err(|_| error!("Could not determine Media State"))
.ok();
return glib::Continue(false)
}
}
}
glib::Continue(true)
}); });
gtk::timeout_add(250, callback);
lock.cancel_connect_clicked(clone!(prog, media_machine => move |_| { // Show or hide the play/delete/download buttons upon widget initialization.
if let Ok(mut m) = prog.lock() { if let Some(prog) = active_dl {
m.cancel(); lock.cancel_connect_clicked(prog.clone());
} drop(lock);
if let Ok(mut lock) = media_machine.try_borrow_mut() { // Setup a callback that will update the progress bar.
if let Ok(episode) = dbqueries::get_episode_widget_from_rowid(id) { update_progressbar_callback(prog.clone(), self.media.clone(), id);
take_mut::take(lock.deref_mut(), |media| {
media.determine_state(
episode.length(),
false,
episode.local_uri().is_some(),
)
});
}
}
}));
drop(lock);
// Setup a callback that will update the progress bar. // Setup a callback that will update the total_size label
update_progressbar_callback(&prog, &media_machine, id); // with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), self.media.clone());
}
// Setup a callback that will update the total_size label Ok(())
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(&prog, &media_machine);
} }
Ok(())
} }
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: &Sender<Action>) -> Result<(), Error> { #[inline]
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
let pd = dbqueries::get_podcast_from_id(ep.podcast_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, sender.clone())?;
// Update Views // Update Views
sender.send(Action::RefreshEpisodesViewBGR)?; sender.send(Action::RefreshEpisodesView)?;
sender.send(Action::RefreshWidgetIfVis)?;
Ok(()) Ok(())
} }
#[inline]
fn on_play_bttn_clicked( fn on_play_bttn_clicked(
episode: &mut EpisodeWidgetQuery, episode: &mut EpisodeWidgetQuery,
title: &Rc<RefCell<TitleMachine>>, title: Rc<RefCell<TitleMachine>>,
sender: &Sender<Action>, sender: Sender<Action>,
) -> Result<(), Error> { ) -> Result<(), Error> {
open_uri(episode.rowid())?; open_uri(episode.rowid())?;
episode.set_played_now()?; episode.set_played_now()?;
@@ -281,21 +244,24 @@ fn open_uri(rowid: i32) -> Result<(), Error> {
#[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(
prog: &Arc<Mutex<manager::Progress>>, prog: Arc<Mutex<manager::Progress>>,
media: &Rc<RefCell<MediaMachine>>, media: Arc<Mutex<MediaMachine>>,
episode_rowid: i32, episode_rowid: i32,
) { ) {
let callback = clone!(prog, media => move || { timeout_add(
progress_bar_helper(&prog, &media, episode_rowid) 400,
.unwrap_or(glib::Continue(false)) clone!(prog, media => move || {
}); progress_bar_helper(prog.clone(), media.clone(), episode_rowid)
timeout_add(300, callback); .unwrap_or(glib::Continue(false))
}),
);
} }
#[inline]
#[allow(if_same_then_else)] #[allow(if_same_then_else)]
fn progress_bar_helper( fn progress_bar_helper(
prog: &Arc<Mutex<manager::Progress>>, prog: Arc<Mutex<manager::Progress>>,
media: &Rc<RefCell<MediaMachine>>, media: Arc<Mutex<MediaMachine>>,
episode_rowid: i32, episode_rowid: i32,
) -> Result<glib::Continue, Error> { ) -> Result<glib::Continue, Error> {
let (fraction, downloaded) = { let (fraction, downloaded) = {
@@ -312,9 +278,8 @@ fn progress_bar_helper(
.file_size(SIZE_OPTS.clone()) .file_size(SIZE_OPTS.clone())
.map_err(|err| format_err!("{}", err))?; .map_err(|err| format_err!("{}", err))?;
if let Ok(mut m) = media.try_borrow_mut() { let mut m = media.lock().unwrap();
m.update_progress(&size, fraction); m.update_progress(&size, fraction);
}
} }
// info!("Fraction: {}", progress_bar.get_fraction()); // info!("Fraction: {}", progress_bar.get_fraction());
@@ -342,18 +307,21 @@ fn progress_bar_helper(
// relying to the RSS feed. // relying to the RSS feed.
#[inline] #[inline]
fn update_total_size_callback( fn update_total_size_callback(
prog: &Arc<Mutex<manager::Progress>>, prog: Arc<Mutex<manager::Progress>>,
media: &Rc<RefCell<MediaMachine>>, media: Arc<Mutex<MediaMachine>>,
) { ) {
let callback = clone!(prog, media => move || { timeout_add(
total_size_helper(&prog, &media).unwrap_or(glib::Continue(true)) 500,
}); clone!(prog, media => move || {
timeout_add(500, callback); total_size_helper(prog.clone(), media.clone()).unwrap_or(glib::Continue(true))
}),
);
} }
#[inline]
fn total_size_helper( fn total_size_helper(
prog: &Arc<Mutex<manager::Progress>>, prog: Arc<Mutex<manager::Progress>>,
media: &Rc<RefCell<MediaMachine>>, media: Arc<Mutex<MediaMachine>>,
) -> Result<glib::Continue, Error> { ) -> Result<glib::Continue, Error> {
// Get the total_bytes. // Get the total_bytes.
let total_bytes = { let total_bytes = {
@@ -365,7 +333,7 @@ fn total_size_helper(
debug!("Total Size: {}", total_bytes); debug!("Total Size: {}", total_bytes);
if total_bytes != 0 { if total_bytes != 0 {
// Update the total_size label // Update the total_size label
if let Ok(mut m) = media.try_borrow_mut() { if let Ok(mut m) = media.lock() {
take_mut::take(m.deref_mut(), |machine| { take_mut::take(m.deref_mut(), |machine| {
machine.set_size(Some(total_bytes as i32)) machine.set_size(Some(total_bytes as i32))
}); });
@@ -382,3 +350,64 @@ fn total_size_helper(
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into(); // let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into();
// delete_local_content(&mut ep).map_err(From::from).map(|_| ()) // delete_local_content(&mut ep).map_err(From::from).map(|_| ())
// } // }
pub fn episodes_listbox(pd: Arc<Podcast>, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
use crossbeam_channel::TryRecvError::*;
use crossbeam_channel::bounded;
let count = dbqueries::get_pd_episodes_count(&pd)?;
let (sender_, receiver) = bounded(1);
rayon::spawn(move || {
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
sender_
.send(episodes)
.expect("Something terrible happened to the channnel");
});
let list = gtk::ListBox::new();
list.set_visible(true);
list.set_selection_mode(gtk::SelectionMode::None);
if count == 0 {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_show.ui");
let container: gtk::Box = builder.get_object("empty_show").unwrap();
list.add(&container);
return Ok(list);
}
gtk::idle_add(clone!(list => move || {
let episodes = match receiver.try_recv() {
Ok(e) => e,
Err(Empty) => return glib::Continue(true),
Err(Disconnected) => return glib::Continue(false),
};
lazy_load(episodes, list.clone(), clone!(sender => move |ep| {
EpisodeWidget::new(ep, sender.clone()).container
}));
glib::Continue(false)
}));
Ok(list)
}
use gtk::{IsA, Widget};
fn lazy_load<T, U, P, Z>(data: T, container: Z, mut predicate: P)
where
T: IntoIterator + 'static,
T::Item: 'static,
Z: ContainerExt + 'static,
P: FnMut(T::Item) -> U + 'static,
U: IsA<Widget>,
{
let mut data = data.into_iter();
gtk::idle_add(move || {
data.next()
.map(|x| container.add(&predicate(x)))
.map(|_| glib::Continue(true))
.unwrap_or(glib::Continue(false))
});
}
+97 -59
View File
@@ -13,7 +13,9 @@ use chrono::prelude::*;
use gtk::prelude::*; use gtk::prelude::*;
use humansize::{file_size_opts as size_opts, FileSize}; use humansize::{file_size_opts as size_opts, FileSize};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use manager::Progress as OtherProgress;
lazy_static! { lazy_static! {
pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = { pub static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
@@ -202,7 +204,7 @@ impl DateMachine {
use self::DateMachine::*; use self::DateMachine::*;
let ts = Utc.timestamp(epoch, 0); let ts = Utc.timestamp(epoch, 0);
let is_old = NOW.year() != ts.year(); let is_old = !(NOW.year() == ts.year());
match (self, is_old) { match (self, is_old) {
// Into Usual // Into Usual
@@ -499,8 +501,13 @@ impl<S> Progress<S> {
self.bar.set_fraction(fraction); self.bar.set_fraction(fraction);
} }
fn cancel_connect_clicked<F: Fn(&gtk::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId { fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
self.cancel.connect_clicked(f) self.cancel.connect_clicked(move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
})
} }
} }
@@ -536,6 +543,71 @@ pub struct Media<X, Y, Z> {
type New<Y> = Media<Download, Y, Hidden>; type New<Y> = Media<Download, Y, Hidden>;
type Playable<Y> = Media<Play, Y, Hidden>; type Playable<Y> = Media<Play, Y, Hidden>;
type InProgress = Media<Hidden, Shown, Shown>; type InProgress = Media<Hidden, Shown, Shown>;
type MediaUnInitialized = Media<UnInitialized, UnInitialized, UnInitialized>;
impl From<New<Shown>> for InProgress {
fn from(f: New<Shown>) -> Self {
f.into_progress()
}
}
impl From<New<Hidden>> for InProgress {
fn from(f: New<Hidden>) -> Self {
f.into_progress()
}
}
impl From<Playable<Shown>> for InProgress {
fn from(f: Playable<Shown>) -> Self {
f.into_progress()
}
}
impl From<Playable<Hidden>> for InProgress {
fn from(f: Playable<Hidden>) -> Self {
f.into_progress()
}
}
impl<Y: Visibility> From<Playable<Y>> for New<Y> {
fn from(f: Playable<Y>) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size,
progress: f.progress,
}
}
}
impl<Y: Visibility> From<New<Y>> for Playable<Y> {
fn from(f: New<Y>) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size,
progress: f.progress,
}
}
}
impl From<MediaUnInitialized> for New<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_fetchable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl From<MediaUnInitialized> for Playable<Hidden> {
fn from(f: MediaUnInitialized) -> Self {
Media {
dl: f.dl.into_playable(),
size: f.size.into_hidden(),
progress: f.progress.into_hidden(),
}
}
}
impl<X, Y, Z> Media<X, Y, Z> { impl<X, Y, Z> Media<X, Y, Z> {
fn set_size(self, s: &str) -> Media<X, Shown, Z> { fn set_size(self, s: &str) -> Media<X, Shown, Z> {
@@ -562,14 +634,6 @@ impl<X, Y, Z> Media<X, Y, Z> {
} }
} }
fn into_new_without(self) -> New<Hidden> {
Media {
dl: self.dl.into_fetchable(),
size: self.size.into_hidden(),
progress: self.progress.into_hidden(),
}
}
fn into_playable(self, size: &str) -> Playable<Shown> { fn into_playable(self, size: &str) -> Playable<Shown> {
Media { Media {
dl: self.dl.into_playable(), dl: self.dl.into_playable(),
@@ -577,14 +641,6 @@ impl<X, Y, Z> Media<X, Y, Z> {
progress: self.progress.into_hidden(), progress: self.progress.into_hidden(),
} }
} }
fn into_playable_without(self) -> Playable<Hidden> {
Media {
dl: self.dl.into_playable(),
size: self.size.into_hidden(),
progress: self.progress.into_hidden(),
}
}
} }
impl<X, Z> Media<X, Shown, Z> { impl<X, Z> Media<X, Shown, Z> {
@@ -663,17 +719,18 @@ impl ButtonsState {
// From whatever to NewWithoutSize // From whatever to NewWithoutSize
(New(m), None, false) => NewWithoutSize(m.hide_size()), (New(m), None, false) => NewWithoutSize(m.hide_size()),
(Playable(m), None, false) => NewWithoutSize(m.into_new_without()), (Playable(m), None, false) => NewWithoutSize(Media::from(m).hide_size()),
(b @ NewWithoutSize(_), None, false) => b, (b @ NewWithoutSize(_), None, false) => b,
(PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into_new_without()), (PlayableWithoutSize(m), None, false) => NewWithoutSize(m.into()),
// From whatever to PlayableWithoutSize // From whatever to PlayableWithoutSize
(New(m), None, true) => PlayableWithoutSize(m.into_playable_without()), (New(m), None, true) => PlayableWithoutSize(Media::from(m).hide_size()),
(Playable(m), None, true) => PlayableWithoutSize(m.hide_size()), (Playable(m), None, true) => PlayableWithoutSize(m.hide_size()),
(NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into_playable_without()), (NewWithoutSize(val), None, true) => PlayableWithoutSize(val.into()),
(b @ PlayableWithoutSize(_), None, true) => b, (b @ PlayableWithoutSize(_), None, true) => b,
// _ => unimplemented!()
} }
} }
@@ -681,10 +738,10 @@ impl ButtonsState {
use self::ButtonsState::*; use self::ButtonsState::*;
match self { match self {
New(m) => m.into_progress(), New(m) => m.into(),
Playable(m) => m.into_progress(), Playable(m) => m.into(),
NewWithoutSize(m) => m.into_progress(), NewWithoutSize(m) => m.into(),
PlayableWithoutSize(m) => m.into_progress(), PlayableWithoutSize(m) => m.into(),
} }
} }
@@ -731,14 +788,14 @@ impl ButtonsState {
} }
} }
fn cancel_connect_clicked<F: Fn(&gtk::Button) + 'static>(&self, f: F) -> glib::SignalHandlerId { fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
use self::ButtonsState::*; use self::ButtonsState::*;
match *self { match *self {
New(ref val) => val.progress.cancel_connect_clicked(f), New(ref val) => val.progress.cancel_connect_clicked(prog),
NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(f), NewWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
Playable(ref val) => val.progress.cancel_connect_clicked(f), Playable(ref val) => val.progress.cancel_connect_clicked(prog),
PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(f), PlayableWithoutSize(ref val) => val.progress.cancel_connect_clicked(prog),
} }
} }
} }
@@ -795,16 +852,13 @@ impl MediaMachine {
} }
} }
pub fn cancel_connect_clicked<F: Fn(&gtk::Button) + 'static>( pub fn cancel_connect_clicked(&self, prog: Arc<Mutex<OtherProgress>>) -> glib::SignalHandlerId {
&self,
f: F,
) -> glib::SignalHandlerId {
use self::MediaMachine::*; use self::MediaMachine::*;
match *self { match *self {
UnInitialized(ref val) => val.progress.cancel_connect_clicked(f), UnInitialized(ref val) => val.progress.cancel_connect_clicked(prog),
Initialized(ref val) => val.cancel_connect_clicked(f), Initialized(ref val) => val.cancel_connect_clicked(prog),
InProgress(ref val) => val.progress.cancel_connect_clicked(f), InProgress(ref val) => val.progress.cancel_connect_clicked(prog),
} }
} }
@@ -817,31 +871,14 @@ impl MediaMachine {
// Into New // Into New
(UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))), (UnInitialized(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
(UnInitialized(m), None, false, false) => { (UnInitialized(m), None, false, false) => Initialized(NewWithoutSize(m.into())),
Initialized(NewWithoutSize(m.into_new_without()))
}
// Into Playable // Into Playable
(UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))), (UnInitialized(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
(UnInitialized(m), None, true, false) => { (UnInitialized(m), None, true, false) => Initialized(PlayableWithoutSize(m.into())),
Initialized(PlayableWithoutSize(m.into_playable_without()))
}
(Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)), (Initialized(bttn), s, dl, false) => Initialized(bttn.determine_state(s, dl)),
(Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()), (Initialized(bttn), _, _, true) => InProgress(bttn.into_progress()),
// Into New
(InProgress(m), Some(s), false, false) => Initialized(New(m.into_new(&s))),
(InProgress(m), None, false, false) => {
Initialized(NewWithoutSize(m.into_new_without()))
}
// Into Playable
(InProgress(m), Some(s), true, false) => Initialized(Playable(m.into_playable(&s))),
(InProgress(m), None, true, false) => {
Initialized(PlayableWithoutSize(m.into_playable_without()))
}
(i @ InProgress(_), _, _, _) => i, (i @ InProgress(_), _, _, _) => i,
} }
} }
@@ -869,6 +906,7 @@ impl MediaMachine {
} }
} }
#[inline]
fn size_helper(bytes: Option<i32>) -> Option<String> { fn size_helper(bytes: Option<i32>) -> Option<String> {
let s = bytes?; let s = bytes?;
if s == 0 { if s == 0 {
+2 -8
View File
@@ -1,13 +1,7 @@
mod empty; mod show;
mod episode; mod episode;
mod episode_states; mod episode_states;
mod home_view;
mod show;
mod shows_view;
pub use self::empty::EmptyView;
pub use self::episode::EpisodeWidget; pub use self::episode::EpisodeWidget;
pub use self::home_view::HomeView;
pub use self::show::ShowWidget; pub use self::show::ShowWidget;
pub use self::show::{mark_all_notif, remove_show_notif}; pub use self::show::mark_all_watched;
pub use self::shows_view::ShowsView;
+62 -218
View File
@@ -1,30 +1,19 @@
use glib; use failure::Error;
// use glib;
use gtk; use gtk;
use gtk::prelude::*; use gtk::prelude::*;
use failure::Error;
use html2pango::markup_from_raw; use html2pango::markup_from_raw;
use open; use open;
use rayon;
use send_cell::SendCell;
use hammond_data::dbqueries;
use hammond_data::utils::delete_show;
use hammond_data::Podcast; use hammond_data::Podcast;
use hammond_data::dbqueries;
use app::Action; use app::Action;
use appnotif::{InAppNotification, UndoState}; use utils::set_image_from_path;
use utils::{self, lazy_load}; use widgets::episode::episodes_listbox;
use widgets::EpisodeWidget;
use std::rc::Rc; use std::sync::Arc;
use std::sync::mpsc::{SendError, Sender}; use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
lazy_static! {
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
Mutex::new(None);
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ShowWidget { pub struct ShowWidget {
@@ -35,8 +24,7 @@ pub struct ShowWidget {
link: gtk::Button, link: gtk::Button,
settings: gtk::MenuButton, settings: gtk::MenuButton,
unsub: gtk::Button, unsub: gtk::Button,
episodes: gtk::ListBox, episodes: gtk::Frame,
podcast_id: Option<i32>,
} }
impl Default for ShowWidget { impl Default for ShowWidget {
@@ -44,7 +32,7 @@ impl Default for ShowWidget {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap(); let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap(); let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let episodes = builder.get_object("episodes").unwrap(); let episodes: gtk::Frame = builder.get_object("episodes").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap(); let cover: gtk::Image = builder.get_object("cover").unwrap();
let description: gtk::Label = builder.get_object("description").unwrap(); let description: gtk::Label = builder.get_object("description").unwrap();
@@ -61,46 +49,45 @@ impl Default for ShowWidget {
link, link,
settings, settings,
episodes, episodes,
podcast_id: None,
} }
} }
} }
impl ShowWidget { impl ShowWidget {
pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> Rc<ShowWidget> { pub fn new(pd: Arc<Podcast>, sender: Sender<Action>) -> ShowWidget {
let mut pdw = ShowWidget::default(); let pdw = ShowWidget::default();
pdw.init(&pd, &sender); 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 pdw
} }
pub fn init(&mut self, pd: &Arc<Podcast>, sender: &Sender<Action>) { pub fn init(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui"); let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
WidgetExt::set_name(&self.container, &pd.id().to_string());
self.unsub self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| { .connect_clicked(clone!(pd, sender => move |bttn| {
on_unsub_button_clicked(pd.clone(), bttn, &sender); if let Err(err) = on_unsub_button_clicked(pd.clone(), bttn, sender.clone()) {
error!("Error: {}", err);
}
})); }));
self.setup_listbox(pd.clone(), sender.clone());
self.set_description(pd.description()); self.set_description(pd.description());
self.podcast_id = Some(pd.id());
self.set_cover(&pd) if let Err(err) = self.set_cover(pd.clone()) {
.map_err(|err| error!("Failed to set a cover: {}", err)) error!("Failed to set a cover: {}", err)
.ok(); }
let link = pd.link().to_owned(); let link = pd.link().to_owned();
self.link.set_tooltip_text(Some(link.as_str())); self.link.set_tooltip_text(Some(link.as_str()));
self.link.connect_clicked(move |_| { self.link.connect_clicked(move |_| {
info!("Opening link: {}", &link); info!("Opening link: {}", &link);
open::that(&link) if let Err(err) = open::that(&link) {
.map_err(|err| error!("Error: {}", err)) error!("Failed to open link: {}", &link);
.map_err(|_| error!("Failed open link: {}", &link)) error!("Error: {}", err);
.ok(); }
}); });
let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap(); let show_menu: gtk::Popover = builder.get_object("show_menu").unwrap();
@@ -111,15 +98,21 @@ impl ShowWidget {
on_played_button_clicked( on_played_button_clicked(
pd.clone(), pd.clone(),
&episodes, &episodes,
&sender sender.clone()
) )
})); }));
self.settings.set_popover(&show_menu); self.settings.set_popover(&show_menu);
} }
/// Populate the listbox with the shows episodes.
fn setup_listbox(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
let listbox = episodes_listbox(pd, sender.clone());
listbox.ok().map(|l| self.episodes.add(&l));
}
/// Set the show cover. /// Set the show cover.
fn set_cover(&self, pd: &Arc<Podcast>) -> Result<(), Error> { fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
utils::set_image_from_path(&self.cover, pd.id(), 256) set_image_from_path(&self.cover, Arc::new(pd.into()), 128)
} }
/// Set the descripton text. /// Set the descripton text.
@@ -127,207 +120,58 @@ impl ShowWidget {
self.description.set_markup(&markup_from_raw(text)); 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. /// Set scrolled window vertical adjustment.
fn set_vadjustment(&self, pd: &Arc<Podcast>) -> Result<(), Error> { pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
let guard = SHOW_WIDGET_VALIGNMENT self.scrolled_window.set_vadjustment(vadjustment)
.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 on_unsub_button_clicked(
fn populate_listbox(
show: &Rc<ShowWidget>,
pd: Arc<Podcast>, pd: Arc<Podcast>,
unsub_button: &gtk::Button,
sender: Sender<Action>, sender: Sender<Action>,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crossbeam_channel::bounded; // hack to get away without properly checking for none.
use crossbeam_channel::TryRecvError::*; // if pressed twice would panic.
unsub_button.hide();
sender.send(Action::RemoveShow(pd))?;
let count = dbqueries::get_pd_episodes_count(&pd)?; sender.send(Action::HeaderBarNormal)?;
sender.send(Action::ShowShowsAnimated)?;
let (sender_, receiver) = bounded(1); // Queue a refresh after the switch to avoid blocking the db.
rayon::spawn(clone!(pd => move || { sender.send(Action::RefreshShowsView)?;
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap(); sender.send(Action::RefreshEpisodesView)?;
// 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
});
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(()) Ok(())
} }
fn on_unsub_button_clicked(pd: Arc<Podcast>, unsub_button: &gtk::Button, sender: &Sender<Action>) { fn on_played_button_clicked(pd: Arc<Podcast>, episodes: &gtk::Frame, 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() { if dim_titles(episodes).is_none() {
error!("Something went horribly wrong when dimming the titles."); error!("Something went horribly wrong when dimming the titles.");
warn!("RUN WHILE YOU STILL CAN!"); warn!("RUN WHILE YOU STILL CAN!");
} }
sender sender.send(Action::MarkAllPlayerNotification(pd)).unwrap();
.send(Action::MarkAllPlayerNotification(pd))
.map_err(|err| error!("Action Sender: {}", err))
.ok();
} }
fn mark_all_watched(pd: &Podcast, sender: &Sender<Action>) -> Result<(), Error> { pub fn mark_all_watched(pd: &Podcast, sender: Sender<Action>) -> Result<(), Error> {
dbqueries::update_none_to_played_now(pd)?; dbqueries::update_none_to_played_now(pd)?;
// Not all widgets migth have been loaded when the mark_all is hit sender.send(Action::RefreshWidgetIfVis)?;
// So we will need to refresh again after it's done. sender.send(Action::RefreshEpisodesView)?;
sender.send(Action::RefreshWidgetIfSame(pd.id()))?; Ok(())
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: // 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(); }` // `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 // 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. // position relative to the EpisodeWidget container gtk::Box.
fn dim_titles(episodes: &gtk::ListBox) -> Option<()> { fn dim_titles(episodes: &gtk::Frame) -> Option<()> {
let children = episodes.get_children(); let listbox = episodes
.get_children()
.remove(0)
.downcast::<gtk::ListBox>()
.ok()?;
let children = listbox.get_children();
for row in children { for row in children {
let row = row.downcast::<gtk::ListBoxRow>().ok()?; let row = row.downcast::<gtk::ListBoxRow>().ok()?;
-167
View File
@@ -1,167 +0,0 @@
use gtk;
use gtk::prelude::*;
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::mpsc::Sender;
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)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
</p> </p>
</description> </description>
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> --> <!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
<bug-database rdf:resource="https://gitlab.gnome.org/World/hammond/issues" /> <bug-database rdf:resource="https://gitlab.gnome.org/alatiera/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>
+1 -1
View File
@@ -3,7 +3,7 @@
project( project(
'hammond', 'rust', 'hammond', 'rust',
version: '0.3.3', version: '0.3.1',
license: 'GPLv3', license: 'GPLv3',
) )
+2 -4
View File
@@ -28,9 +28,7 @@
"--share=network" "--share=network"
], ],
"env" : { "env" : {
"CARGO_HOME" : "/run/build/Hammond/cargo", "CARGO_HOME" : "/run/build/Hammond/cargo"
"RUST_BACKTRACE" : "1",
"RUSTFLAGS" : "--cfg rayon_unstable"
} }
}, },
"modules" : [ "modules" : [
@@ -40,7 +38,7 @@
"sources" : [ "sources" : [
{ {
"type" : "git", "type" : "git",
"url" : "https://gitlab.gnome.org/World/hammond.git", "url" : "https://gitlab.gnome.org/alatiera/Hammond.git",
"branch" : "master" "branch" : "master"
} }
] ]
+2
View File
@@ -11,3 +11,5 @@ condense_wildcard_suffixes = false
format_strings = true format_strings = true
normalize_comments = true normalize_comments = true
reorder_imports = true reorder_imports = true
reorder_imported_names = true
reorder_imports_in_group = true
-1
View File
@@ -1,6 +1,5 @@
#!/bin/sh #!/bin/sh
export CARGO_HOME=$1/target/cargo-home export CARGO_HOME=$1/target/cargo-home
export RUSTFLAGS="--cfg rayon_unstable"
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2 cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2
-1
View File
@@ -13,7 +13,6 @@ cp -rf hammond-data $DIST
cp -rf hammond-gtk $DIST cp -rf hammond-gtk $DIST
cp -rf hammond-downloader $DIST cp -rf hammond-downloader $DIST
cp Cargo.toml $DIST cp Cargo.toml $DIST
cp Cargo.lock $DIST
cp configure $DIST cp configure $DIST
cp meson.build $DIST cp meson.build $DIST
cp Hammond.doap $DIST cp Hammond.doap $DIST